packages feed

binrep-0.1.0: src/Binrep/Codec.hs

module Binrep.Codec where

import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as BL
import Data.ByteString.Builder qualified as B
import Data.Serialize

-- | Types that can be coded precisely between binary and a Haskell type.
--
-- This class looks identical to cereal's 'Serialize', but we take a different
-- approach to instances.
--
-- 'Data.Serialize.Serialize' defines an internal binary codec for common
-- Haskell types. It makes implicit decisions, such as big-endian for all
-- integer types, and coding lists with a (big-endian) 'Word64' length prefix.
-- It only works with other data serialized with the same library.
--
-- This typeclass defines composable binary codec combinators. If you want to
-- write a codec for a 'Word64', it needs to specify its endianness in the type.
-- If you want to write a codec for a list with a 'Word8' length prefix, it must
-- come with a proof that it's not oversized.
--
-- The idea is to use this typeclass along with various annotated newtypes to
-- allow defining a Haskell type's binary representation directly in the types.
-- In cases where you're doing intermediate serialization and not much else, it
-- may be convenient. You will need to do a bunch of (free at runtime) wrapping
-- though.
class BinaryCodec a where
    toBin   :: Putter a     -- ^ Encode to   binary. Same as cereal's 'put'.
    fromBin :: Get a        -- ^ Decode from binary. Same as cereal's 'get'.

-- | Run the encoder for a supporting type.
binEncode :: BinaryCodec a => a -> BS.ByteString
binEncode = runPut . toBin

-- | Run the decoder a supporting type.
binDecode :: BinaryCodec a => BS.ByteString -> Either String a
binDecode = runGet fromBin

-- | Serialize each element in order. No length indicator, so parse until either
--   error or EOF. Usually not what you want, but sometimes used at the "top" of
--   binary formats.
instance BinaryCodec a => BinaryCodec [a] where
    toBin as = mapM_ toBin as
    fromBin = go []
      where
        go as = do
            a <- fromBin
            isEmpty >>= \case
              True -> return $ reverse $ a : as
              False -> go $ a : as

-- | Types that can be coded between binary and a Haskell type given some
--   runtime information.
--
-- We can't prove something related to a value and pass that proof along without
-- dependent types, meaning we can't split validation from encoding like in
-- 'BinaryCodec', so encoding can fail. However, by allowing an arbitrary
-- environment, we can define many more convenient instances.
--
-- For example, you can't write a 'BinaryCodec' instance for 'Word16' because it
-- doesn't specify its endianness. But you can define 'BinaryCodecWith
-- Endianness Word16'! This was, you can decide how much of the binary schema
-- you want to place directly in the types, and how much to configure
-- dynamically.
--
-- This class defaults to the free implementation provided by 'BinaryCodec',
-- which ignores the environment and wraps serializing with 'Right'.
class BinaryCodecWith r a where
    -- | Encode to binary with the given environment.
    toBinWith   :: r -> a -> Either String B.Builder
    default toBinWith :: BinaryCodec a => r -> a -> Either String B.Builder
    toBinWith = const $ Right . execPut . toBin

    -- | Decode to binary with the given environment.
    fromBinWith :: r -> Get a
    default fromBinWith :: BinaryCodec a => r -> Get a
    fromBinWith = const fromBin

-- | Run the encoder for a supporting type using the given environment.
binEncodeWith :: BinaryCodecWith r a => r -> a -> Either String BS.ByteString
binEncodeWith r a = BL.toStrict . B.toLazyByteString <$> toBinWith r a

-- | Run the decoder for a supporting type using the given environment.
binDecodeWith :: BinaryCodecWith r a => r -> BS.ByteString -> Either String a
binDecodeWith = runGet . fromBinWith