keiki-0.2.0.0: src/Keiki/Generics.hs
{-# LANGUAGE TypeFamilies #-}
-- | DX spike (EP-2 follow-up): generic-derived inversions between
-- Haskell records and 'RegFile's so users can build 'InCtor' values
-- without hand-rolling RCons-towers.
--
-- Status: experimental. See the EP-2 retrospective for context.
module Keiki.Generics
( -- * Generic-derived InCtor
mkInCtor,
mkInCtor0,
mkInCtorVia,
-- * Generic-derived WireCtor
mkWireCtor,
mkWireCtor0,
mkWireCtorVia,
FieldsOf,
FieldsOfRep,
-- * Slot-list deriving
RegFieldsOf,
RegFieldsOfRep,
-- * Empty register file
EmptyRegFile (..),
-- * Sum-walking machinery
GHasCtor (..),
GHasCtorIf (..),
NameInRep,
-- * Internals
GRecord (..),
GTuple (..),
Append,
appendRegFile,
SplitRegFile (..),
ConcatT,
SplitT (..),
)
where
import Data.Kind (Type)
import Data.Proxy (Proxy (..))
import Data.Type.Bool (type (||))
import GHC.Generics
import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)
import Keiki.Core
-- | Walk a 'GHC.Generics' record representation to/from a 'RegFile'.
-- Slot lists are derived from the record's field metadata: every
-- selector @M1 S ('MetaSel ('Just name) ...)@ contributes a slot
-- @'(name, fieldType)@; products concatenate.
class GRecord (rep :: Type -> Type) (ifs :: [Slot]) | rep -> ifs where
gToRegFile :: rep a -> RegFile ifs
gFromRegFile :: RegFile ifs -> rep a
-- M1 D wrapper (data type metadata): pass through.
instance (GRecord inner ifs) => GRecord (M1 D meta inner) ifs where
gToRegFile (M1 r) = gToRegFile r
gFromRegFile rf = M1 (gFromRegFile rf)
-- M1 C wrapper (constructor metadata): pass through.
instance (GRecord inner ifs) => GRecord (M1 C meta inner) ifs where
gToRegFile (M1 r) = gToRegFile r
gFromRegFile rf = M1 (gFromRegFile rf)
-- Named selector with a leaf field: one slot.
instance
(KnownSymbol name) =>
GRecord
(M1 S ('MetaSel ('Just name) su ss ds) (K1 r t))
'[ '(name, t)]
where
gToRegFile (M1 (K1 v)) = RCons (Proxy @name) v RNil
gFromRegFile (RCons _ v _) = M1 (K1 v)
-- No-arg constructor: empty slot list.
instance GRecord U1 '[] where
gToRegFile U1 = RNil
gFromRegFile RNil = U1
-- Product: concatenate slot lists.
instance
( GRecord l ls,
GRecord r rs,
Append ls rs ~ ifs,
SplitRegFile ls rs
) =>
GRecord (l :*: r) ifs
where
gToRegFile (a :*: b) = appendRegFile (gToRegFile a) (gToRegFile b)
gFromRegFile rf = case splitRegFile @ls @rs rf of
(lrf, rrf) -> gFromRegFile lrf :*: gFromRegFile rrf
-- | Type-level append for slot lists.
type family Append (xs :: [Slot]) (ys :: [Slot]) :: [Slot] where
Append '[] ys = ys
Append (x ': xs) ys = x ': Append xs ys
-- | Value-level append for register files.
appendRegFile :: RegFile ls -> RegFile rs -> RegFile (Append ls rs)
appendRegFile RNil rs = rs
appendRegFile (RCons p v xs) rs = RCons p v (appendRegFile xs rs)
-- | Split a register file at the boundary between two slot lists.
class SplitRegFile (ls :: [Slot]) (rs :: [Slot]) where
splitRegFile :: RegFile (Append ls rs) -> (RegFile ls, RegFile rs)
instance SplitRegFile '[] rs where
splitRegFile rf = (RNil, rf)
instance (SplitRegFile ls rs) => SplitRegFile ('(s, t) ': ls) rs where
splitRegFile (RCons p v rest) =
case splitRegFile @ls @rs rest of
(lrf, rrf) -> (RCons p v lrf, rrf)
-- | Build an 'InCtor' from a constructor name, a sum-side matcher,
-- and a pack function. The 'RegFile' inversion is derived from the
-- record's 'GHC.Generics.Generic' instance — no RCons-tower required.
--
-- Example:
--
-- > inCtorStart :: InCtor UserCmd
-- > '[ '("email", Email)
-- > , '("confirmCode", ConfirmationCode)
-- > , '("at", UTCTime)
-- > ]
-- > inCtorStart = mkInCtor "StartRegistration"
-- > (\case StartRegistration d -> Just d; _ -> Nothing)
-- > StartRegistration
--
-- The slot list is inferred from @StartRegistrationData@'s 'Generic'
-- field metadata. The record type must have @deriving (Generic)@.
mkInCtor ::
forall ci d ifs.
( Generic d,
GRecord (Rep d) ifs,
AssembleRegFile ifs,
KnownSlotNames ifs
) =>
String ->
(ci -> Maybe d) ->
(d -> ci) ->
InCtor ci ifs
mkInCtor name match wrap =
InCtor
{ icName = name,
icMatch = \ci -> case match ci of
Just d -> Just (gToRegFile (from d))
Nothing -> Nothing,
icBuild = \rf -> wrap (to (gFromRegFile rf))
}
-- | Build an 'InCtor' for a no-payload (singleton) constructor. The
-- 'icMatch' compares against the named singleton via 'Eq'; 'icBuild'
-- ignores the empty 'RegFile' and returns the singleton.
--
-- Example:
--
-- > inCtorContinue :: InCtor UserCmd '[]
-- > inCtorContinue = mkInCtor0 "Continue" Continue
mkInCtor0 :: forall ci. (Eq ci) => String -> ci -> InCtor ci '[]
mkInCtor0 name singleton =
InCtor
{ icName = name,
icMatch = \ci -> if ci == singleton then Just RNil else Nothing,
icBuild = \RNil -> singleton
}
-- * Generic-derived WireCtor ----------------------------------------------
-- | Walk a 'GHC.Generics' record representation to/from the nested-
-- pair tuple shape that 'WireCtor' / 'OutFields' carry. A record with
-- fields @f1, f2, f3@ corresponds to the tuple @(f1, (f2, (f3, ())))@.
class GTuple (rep :: Type -> Type) (fs :: Type) | rep -> fs where
gToTuple :: rep a -> fs
gFromTuple :: fs -> rep a
instance (GTuple inner fs) => GTuple (M1 D meta inner) fs where
gToTuple (M1 r) = gToTuple r
gFromTuple t = M1 (gFromTuple t)
instance (GTuple inner fs) => GTuple (M1 C meta inner) fs where
gToTuple (M1 r) = gToTuple r
gFromTuple t = M1 (gFromTuple t)
instance GTuple (M1 S meta (K1 r t)) (t, ()) where
gToTuple (M1 (K1 v)) = (v, ())
gFromTuple (v, ()) = M1 (K1 v)
instance GTuple U1 () where
gToTuple U1 = ()
gFromTuple () = U1
instance
( GTuple l ls,
GTuple r rs,
ConcatT ls rs ~ fs,
SplitT ls rs
) =>
GTuple (l :*: r) fs
where
gToTuple (a :*: b) = appendT (gToTuple a) (gToTuple b)
gFromTuple t = case splitT @ls @rs t of
(lt, rt) -> gFromTuple lt :*: gFromTuple rt
-- | Type-level concat for nested-pair tuples. @ConcatT (f1, (f2, ())) (f3, ()) ~ (f1, (f2, (f3, ())))@.
type family ConcatT (a :: Type) (b :: Type) :: Type where
ConcatT () b = b
ConcatT (x, xs) b = (x, ConcatT xs b)
-- | Split a concatenated nested-pair tuple back into its halves; also
-- the inverse direction (append).
class SplitT (a :: Type) (b :: Type) where
splitT :: ConcatT a b -> (a, b)
appendT :: a -> b -> ConcatT a b
instance SplitT () b where
splitT b = ((), b)
appendT () b = b
instance (SplitT xs b) => SplitT (x, xs) b where
splitT (x, rest) = case splitT @xs @b rest of
(a, c) -> ((x, a), c)
appendT (x, xs) b = (x, appendT xs b)
-- | Resolve a record type to its nested-pair field tuple. With this
-- alias, @WireCtor UserEvent (FieldsOf RegistrationStartedData)@
-- replaces the hand-written @WireCtor UserEvent (Email,
-- (ConfirmationCode, (UTCTime, ())))@.
type FieldsOf d = FieldsOfRep (Rep d)
-- | The nested-pair tuple shape derived from a 'GHC.Generics' Rep.
type family FieldsOfRep (rep :: Type -> Type) :: Type where
FieldsOfRep (M1 D _ inner) = FieldsOfRep inner
FieldsOfRep (M1 C _ inner) = FieldsOfRep inner
FieldsOfRep (M1 S _ (K1 _ t)) = (t, ())
FieldsOfRep U1 = ()
FieldsOfRep (l :*: r) = ConcatT (FieldsOfRep l) (FieldsOfRep r)
-- | Resolve a record type to its 'RegFile' slot list. With this
-- alias, @InCtor UserCmd (RegFieldsOf StartRegistrationData)@
-- replaces the hand-written @InCtor UserCmd '[ '("email", Email),
-- '("confirmCode", ConfirmationCode), '("at", UTCTime) ]@.
type RegFieldsOf d = RegFieldsOfRep (Rep d)
-- | The slot-list shape derived from a 'GHC.Generics' Rep. Mirrors
-- 'FieldsOfRep' but emits @[Slot]@ instead of a nested-pair tuple,
-- preserving the selector name on every field.
type family RegFieldsOfRep (rep :: Type -> Type) :: [Slot] where
RegFieldsOfRep (M1 D _ inner) = RegFieldsOfRep inner
RegFieldsOfRep (M1 C _ inner) = RegFieldsOfRep inner
RegFieldsOfRep (M1 S ('MetaSel ('Just n) _ _ _) (K1 _ t)) =
'[ '(n, t)]
RegFieldsOfRep U1 = '[]
RegFieldsOfRep (l :*: r) =
Append
(RegFieldsOfRep l)
(RegFieldsOfRep r)
-- | Build a 'WireCtor' from a constructor name, a sum-side matcher,
-- and a pack function. The nested-pair conversion is derived from the
-- record's 'GHC.Generics.Generic' instance.
--
-- Example:
--
-- > wireRegistrationStarted
-- > :: WireCtor UserEvent (FieldsOf RegistrationStartedData)
-- > wireRegistrationStarted = mkWireCtor "RegistrationStarted"
-- > (\case RegistrationStarted d -> Just d; _ -> Nothing)
-- > RegistrationStarted
mkWireCtor ::
forall co d fs.
( Generic d,
GTuple (Rep d) fs
) =>
String ->
(co -> Maybe d) ->
(d -> co) ->
WireCtor co fs
mkWireCtor name match wrap =
WireCtor
{ wcName = name,
wcMatch = \co -> case match co of
Just d -> Just (gToTuple (from d))
Nothing -> Nothing,
wcBuild = \fs -> wrap (to (gFromTuple fs))
}
-- | Build a 'WireCtor' for a no-payload (singleton) event constructor —
-- the event-side twin of 'mkInCtor0'. Its field tuple is @()@ (a
-- payload-free event carries nothing), matching @'OutFields' rs ci ()@ /
-- @OFNil@. 'wcMatch' compares against the named singleton via 'Eq';
-- 'wcBuild' ignores the empty tuple and returns the singleton. The
-- @'Eq' co@ constraint matches 'mkInCtor0'\'s @'Eq' ci@; event sums in
-- this codebase already derive 'Eq', so it is not a new burden.
--
-- Example:
--
-- > wireOpened :: WireCtor DoorEvent ()
-- > wireOpened = mkWireCtor0 "Opened" Opened
mkWireCtor0 :: forall co. (Eq co) => String -> co -> WireCtor co ()
mkWireCtor0 name singleton =
WireCtor
{ wcName = name,
wcMatch = \co -> if co == singleton then Just () else Nothing,
wcBuild = \() -> singleton
}
-- * Empty register file ---------------------------------------------------
-- | Derive an initial 'RegFile' for any slot list. Every slot is
-- pre-bound to a deferred error tagged with the slot's name so reads
-- of an uninitialized slot crash with a targeted message instead of
-- a silent bottom. Every slot must be written before the register file is
-- read or encoded; 'emptyRegFile' is an initialization scaffold, not an
-- encodable snapshot.
class EmptyRegFile (rs :: [Slot]) where
emptyRegFile :: RegFile rs
instance EmptyRegFile '[] where
emptyRegFile = RNil
instance
(KnownSymbol s, EmptyRegFile rs) =>
EmptyRegFile ('(s, r) ': rs)
where
emptyRegFile =
RCons
(Proxy @s)
( error
( "uninit: "
++ symbolVal (Proxy @s)
++ " (slot read before first write; a RegFile must be fully initialized before it is read or encoded)"
)
)
emptyRegFile
-- * Sum-walking machinery -------------------------------------------------
-- | Does the constructor named @n@ appear anywhere in the 'Generic'
-- representation @rep@? Used to dispatch sum-side resolution to the
-- correct branch.
type family NameInRep (n :: Symbol) (rep :: Type -> Type) :: Bool where
NameInRep n (M1 D _ inner) = NameInRep n inner
NameInRep n (l :+: r) = NameInRep n l || NameInRep n r
NameInRep n (M1 C ('MetaCons n _ _) _) = 'True
NameInRep _ _ = 'False
-- | Walk a 'Generic' representation looking for a constructor named
-- @name@; resolve its payload type @d@. Two payload shapes are
-- supported:
--
-- * @M1 C ('MetaCons name _ _) (M1 S _ (K1 _ d))@ — single-field
-- constructor wrapping a record value of type @d@.
-- * @M1 C ('MetaCons name _ _) U1@ — no-payload constructor;
-- @d ~ ()@.
--
-- The functional dependency @name rep -> d@ pins the payload uniquely.
class
GHasCtor (name :: Symbol) (rep :: Type -> Type) (d :: Type)
| name rep -> d
where
gMatchCtor :: rep a -> Maybe d
gBuildCtor :: d -> rep a
-- Pass through the data-type wrapper.
instance (GHasCtor n inner d) => GHasCtor n (M1 D meta inner) d where
gMatchCtor (M1 r) = gMatchCtor @n r
gBuildCtor d = M1 (gBuildCtor @n d)
-- Match a constructor whose payload is a single record value.
instance GHasCtor n (M1 C ('MetaCons n fix lazy) (M1 S meta (K1 r d))) d where
gMatchCtor (M1 (M1 (K1 d))) = Just d
gBuildCtor d = M1 (M1 (K1 d))
-- Match a no-payload constructor; payload is the unit type '()'.
instance GHasCtor n (M1 C ('MetaCons n fix lazy) U1) () where
gMatchCtor (M1 U1) = Just ()
gBuildCtor () = M1 U1
-- Sum dispatch: pick the side that contains the named constructor.
instance
( hasL ~ NameInRep n l,
GHasCtorIf hasL n l r d
) =>
GHasCtor n (l :+: r) d
where
gMatchCtor x = gMatchCtorIf @hasL @n @l @r x
gBuildCtor d = gBuildCtorIf @hasL @n @l @r d
-- | Sum-dispatch helper: reduce 'GHasCtor' on @l :+: r@ to a
-- 'GHasCtor' on the side that contains the named constructor.
class
GHasCtorIf
(b :: Bool)
(n :: Symbol)
(l :: Type -> Type)
(r :: Type -> Type)
(d :: Type)
| b n l r -> d
where
gMatchCtorIf :: (l :+: r) a -> Maybe d
gBuildCtorIf :: d -> (l :+: r) a
instance (GHasCtor n l d) => GHasCtorIf 'True n l r d where
gMatchCtorIf (L1 x) = gMatchCtor @n x
gMatchCtorIf (R1 _) = Nothing
gBuildCtorIf d = L1 (gBuildCtor @n d)
instance (GHasCtor n r d) => GHasCtorIf 'False n l r d where
gMatchCtorIf (L1 _) = Nothing
gMatchCtorIf (R1 x) = gMatchCtor @n x
gBuildCtorIf d = R1 (gBuildCtor @n d)
-- * Generic-derived InCtor / WireCtor (Via builders) ----------------------
-- | Build an 'InCtor' from a constructor name alone. The sum-side
-- match\/wrap pair and the record-side RegFile inversion are both
-- derived from the 'Generic' representations of @ci@ and the inferred
-- payload @d@. With no-payload constructors (e.g. 'Continue') the
-- inferred slot list is @\'[]@.
--
-- Example:
--
-- > inCtorStart :: InCtor UserCmd StartFields
-- > inCtorStart = mkInCtorVia @"StartRegistration"
-- >
-- > inCtorContinue :: InCtor UserCmd '[]
-- > inCtorContinue = mkInCtorVia @"Continue"
mkInCtorVia ::
forall (name :: Symbol) ci d ifs.
( KnownSymbol name,
Generic ci,
GHasCtor name (Rep ci) d,
Generic d,
GRecord (Rep d) ifs,
AssembleRegFile ifs,
KnownSlotNames ifs
) =>
InCtor ci ifs
mkInCtorVia =
InCtor
{ icName = symbolVal (Proxy @name),
icMatch = \ci -> case gMatchCtor @name (from ci) of
Just d -> Just (gToRegFile (from d))
Nothing -> Nothing,
icBuild = \rf -> to (gBuildCtor @name (to (gFromRegFile rf) :: d))
}
-- | Build a 'WireCtor' from a constructor name alone. Mirrors
-- 'mkInCtorVia' on the wire side: the nested-pair field tuple comes
-- from the inferred payload's 'Generic' field metadata.
--
-- Example:
--
-- > wireRegistrationStarted
-- > :: WireCtor UserEvent (FieldsOf RegistrationStartedData)
-- > wireRegistrationStarted = mkWireCtorVia @"RegistrationStarted"
mkWireCtorVia ::
forall (name :: Symbol) co d fs.
( KnownSymbol name,
Generic co,
GHasCtor name (Rep co) d,
Generic d,
GTuple (Rep d) fs
) =>
WireCtor co fs
mkWireCtorVia =
WireCtor
{ wcName = symbolVal (Proxy @name),
wcMatch = \co -> case gMatchCtor @name (from co) of
Just d -> Just (gToTuple (from d))
Nothing -> Nothing,
wcBuild = \fs -> to (gBuildCtor @name (to (gFromTuple fs) :: d))
}