packages feed

keiki-0.2.0.0: src/Keiki/Shape.hs

{-# LANGUAGE DefaultSignatures #-}

-- | The /shape hash/ for @RegFile rs@.
--
-- A snapshot persister (see keiro's @StateCodec (s, RegFile rs)@) needs a
-- compact discriminator for the type-level slot list. 'regFileShapeHash'
-- provides it: a SHA-256 of a canonical, deterministic rendering of every
-- slot's name and type. Built-in scalar and container names are pinned to
-- Haskell-source spellings, so GHC's internal module reorganizations do not
-- invalidate their hashes.
--
-- The hash is sensitive to structural changes (slot rename / addition /
-- removal / reordering / type change) and insensitive to incidental
-- changes (GHC patch version, cabal dependency tree, the slot type's
-- typeclass instances). See @docs\/plans\/36-regfile-json-codec-and-shape-hash-for-snapshot-persistence.md@
-- §3 R3–R5 for the contract and §4 for the schema-evolution cases the
-- hash catches.
--
-- User-defined types that use the 'CanonicalTypeName' default retain their
-- defining module in the canonical name and are stable only while that module
-- path remains stable. Override 'canonicalTypeName' to pin an application-owned
-- name when that stronger guarantee is required.
--
-- This module is the keiki-side primitive. The JSON codec lives in the
-- sibling package @keiki-codec-json@. Together they are the two halves
-- of the snapshot story: the hash discriminates eligible snapshots; the
-- codec serialises the eligible ones.
module Keiki.Shape
  ( -- * Shape hash
    KnownRegFileShape (..),
    regFileShapeHash,

    -- * Per-type canonical name (escape hatch)
    CanonicalTypeName (..),

    -- * TypeRep rendering
    renderStableTypeRep,

    -- * SHA-256 helper
    sha256Hex,
  )
where

import Crypto.Hash.SHA256 qualified as SHA256
import Data.Bits (shiftR, (.&.))
import Data.ByteString qualified as BS
import Data.Char (intToDigit)
import Data.Int (Int16, Int32, Int64, Int8)
import Data.Proxy (Proxy (..))
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Encoding qualified as TE
import Data.Time.Calendar (Day)
import Data.Time.Clock (UTCTime)
import Data.Typeable (Typeable)
import Data.Word (Word16, Word32, Word64, Word8)
import GHC.TypeLits (KnownSymbol, symbolVal)
import Keiki.Core (Slot)
import Type.Reflection
  ( SomeTypeRep (..),
    TypeRep,
    someTypeRep,
    splitApps,
    tyConModule,
    tyConName,
  )

-- * Canonical type names ----------------------------------------------------

-- | Stable, human-readable name for a slot type. The default
-- implementation uses 'renderStableTypeRep' on the type's 'Typeable'
-- runtime representation; users with stability concerns (a slot type
-- whose defining module is likely to be renamed, or a slot type that
-- straddles libraries with unstable module layouts) can override and
-- pin the name explicitly. Built-in containers resolve each argument through
-- this class, so an override for @Foo@ also appears inside @Maybe Foo@,
-- @[Foo]@, @Either Foo b@, and tuples.
--
-- See P9 in EP-36 (@docs\/plans\/36-regfile-json-codec-and-shape-hash-for-snapshot-persistence.md@).
class CanonicalTypeName a where
  canonicalTypeName :: Proxy a -> Text
  default canonicalTypeName :: (Typeable a) => Proxy a -> Text
  canonicalTypeName p = renderStableTypeRep (someTypeRep p)

-- ** Built-in instances ------------------------------------------------------

--
-- Pinned instances for the common scalar and primitive container types that a
-- typical 'RegFile' carries. These names deliberately contain no defining
-- module path. User-defined instances may use the 'Typeable' default or supply
-- an equally explicit application-owned name.

instance CanonicalTypeName () where
  canonicalTypeName _ = T.pack "()"

instance CanonicalTypeName Bool where
  canonicalTypeName _ = T.pack "Bool"

instance CanonicalTypeName Char where
  canonicalTypeName _ = T.pack "Char"

instance CanonicalTypeName Int where
  canonicalTypeName _ = T.pack "Int"

instance CanonicalTypeName Int8 where
  canonicalTypeName _ = T.pack "Int8"

instance CanonicalTypeName Int16 where
  canonicalTypeName _ = T.pack "Int16"

instance CanonicalTypeName Int32 where
  canonicalTypeName _ = T.pack "Int32"

instance CanonicalTypeName Int64 where
  canonicalTypeName _ = T.pack "Int64"

instance CanonicalTypeName Integer where
  canonicalTypeName _ = T.pack "Integer"

instance CanonicalTypeName Word where
  canonicalTypeName _ = T.pack "Word"

instance CanonicalTypeName Word8 where
  canonicalTypeName _ = T.pack "Word8"

instance CanonicalTypeName Word16 where
  canonicalTypeName _ = T.pack "Word16"

instance CanonicalTypeName Word32 where
  canonicalTypeName _ = T.pack "Word32"

instance CanonicalTypeName Word64 where
  canonicalTypeName _ = T.pack "Word64"

instance CanonicalTypeName Double where
  canonicalTypeName _ = T.pack "Double"

instance CanonicalTypeName Float where
  canonicalTypeName _ = T.pack "Float"

instance CanonicalTypeName Text where
  canonicalTypeName _ = T.pack "Text"

instance CanonicalTypeName UTCTime where
  canonicalTypeName _ = T.pack "UTCTime"

instance CanonicalTypeName Day where
  canonicalTypeName _ = T.pack "Day"

instance (CanonicalTypeName a) => CanonicalTypeName (Maybe a) where
  canonicalTypeName _ =
    T.concat [T.pack "Maybe(", canonicalTypeName (Proxy @a), T.pack ")"]

instance (CanonicalTypeName a) => CanonicalTypeName [a] where
  canonicalTypeName _ =
    T.concat [T.pack "[](", canonicalTypeName (Proxy @a), T.pack ")"]

instance (CanonicalTypeName a, CanonicalTypeName b) => CanonicalTypeName (Either a b) where
  canonicalTypeName _ =
    T.concat
      [ T.pack "Either(",
        canonicalTypeName (Proxy @a),
        T.pack ",",
        canonicalTypeName (Proxy @b),
        T.pack ")"
      ]

instance (CanonicalTypeName a, CanonicalTypeName b) => CanonicalTypeName (a, b) where
  canonicalTypeName _ =
    T.concat
      [ T.pack "(,)(",
        canonicalTypeName (Proxy @a),
        T.pack ",",
        canonicalTypeName (Proxy @b),
        T.pack ")"
      ]

instance
  (CanonicalTypeName a, CanonicalTypeName b, CanonicalTypeName c) =>
  CanonicalTypeName (a, b, c)
  where
  canonicalTypeName _ =
    T.concat
      [ T.pack "(,,)(",
        canonicalTypeName (Proxy @a),
        T.pack ",",
        canonicalTypeName (Proxy @b),
        T.pack ",",
        canonicalTypeName (Proxy @c),
        T.pack ")"
      ]

-- * Shape hash --------------------------------------------------------------

-- | The class governing slot-lists that carry a shape hash. The
-- inductive method 'regFileShapeCanonical' assembles the pre-hash
-- canonical encoding; 'regFileShapeHash' (top-level, below) is the
-- SHA-256 of that encoding in lower-case hex.
--
-- Per EP-36 §3 R3 the hash is a single SHA-256 over the byte
-- concatenation of, for each slot in slot-list order,
--
-- > <slotSymbol> ":" <canonicalTypeName> ";"
--
-- with the empty list anchored at the literal canonical form
-- @"regfile:0"@. The recursive structure of the class is therefore
-- /string/ concatenation, not nested hashing — the byte-string is built
-- end-to-end and hashed once. This means a slot-list of length /n/
-- performs exactly one SHA-256, not /n/ chained ones.
class KnownRegFileShape (rs :: [Slot]) where
  -- | The full canonical pre-hash encoding of the slot list. Exposed
  -- so that consumers can attach their own hash algorithm or use the
  -- canonical form for debugging; 'regFileShapeHash' wraps this in
  -- SHA-256.
  regFileShapeCanonical :: Proxy rs -> Text

instance KnownRegFileShape '[] where
  regFileShapeCanonical _ = T.pack "regfile:0"

instance
  ( KnownSymbol s,
    CanonicalTypeName t,
    KnownRegFileShape rs
  ) =>
  KnownRegFileShape ('(s, t) ': rs)
  where
  regFileShapeCanonical _ =
    T.concat
      [ T.pack (symbolVal (Proxy @s)),
        T.pack ":",
        canonicalTypeName (Proxy @t),
        T.pack ";",
        regFileShapeCanonical (Proxy @rs)
      ]

-- | Shape hash of a slot list, as lower-case hexadecimal SHA-256 over
-- the UTF-8 bytes of 'regFileShapeCanonical'. Pure, no 'IO'.
regFileShapeHash :: forall rs. (KnownRegFileShape rs) => Proxy rs -> Text
regFileShapeHash p = sha256Hex (regFileShapeCanonical p)

-- * TypeRep rendering -------------------------------------------------------

-- | Render a 'SomeTypeRep' as a stable, application-tree-shaped string.
-- Each 'TyCon' contributes @<tyConModule>.<tyConName>@; applied type
-- arguments are rendered recursively and surrounded by parentheses,
-- comma-separated.
--
-- Examples (the exact module names depend on the GHC base layout; the
-- shape is what's guaranteed):
--
-- > renderStableTypeRep (someTypeRep (Proxy @Int))         = "GHC.Types.Int"
-- > renderStableTypeRep (someTypeRep (Proxy @(Maybe Int))) = "GHC.Internal.Maybe.Maybe(GHC.Types.Int)"
--
-- The implementation uses only 'tyConModule', 'tyConName', and
-- 'splitApps' — never 'tyConPackage' (which varies with cabal version
-- pins), never 'Show' on 'TypeRep' (which is not contractually stable),
-- and never the raw 'Type.Reflection.Fingerprint'. See EP-36 §3 R5
-- and §5 P5.
renderStableTypeRep :: SomeTypeRep -> Text
renderStableTypeRep (SomeTypeRep tr) = renderTypeRep tr

renderTypeRep :: forall k (a :: k). TypeRep a -> Text
renderTypeRep tr =
  let (tc, args) = splitApps tr
      base =
        T.pack (tyConModule tc)
          <> T.pack "."
          <> T.pack (tyConName tc)
   in case args of
        [] -> base
        _ ->
          base
            <> T.pack "("
            <> T.intercalate (T.pack ",") (map renderStableTypeRep args)
            <> T.pack ")"

-- * SHA-256 helper ----------------------------------------------------------

-- | SHA-256 over the UTF-8 encoding of the input, rendered as
-- lower-case hexadecimal.
sha256Hex :: Text -> Text
sha256Hex =
  T.pack . concatMap byteToHex . BS.unpack . SHA256.hash . TE.encodeUtf8

byteToHex :: Word8 -> String
byteToHex b =
  [ intToDigit (fromIntegral (b `shiftR` 4)),
    intToDigit (fromIntegral (b .&. 0x0F))
  ]