packages feed

spire-protobuf-0.1.0.0: src/Spire/Protobuf/Encode.hs

{-# LANGUAGE AllowAmbiguousTypes #-}

-- | Length-memoizing builder and encoding for protobuf values.
--
-- The key insight: protobuf submessages need their byte length encoded
-- as a varint /before/ the content. A normal 'Builder' doesn't know its
-- length until materialized. 'ProtoBuilder' tracks the byte count
-- alongside the builder, so submessage encoding is a single pass.
module Spire.Protobuf.Encode
  ( -- * Length-memoizing Builder
    ProtoBuilder (..)
  , runProtoBuilder
  , pbEmpty
    -- * ProtoEncode class
  , ProtoEncode (..)
    -- * Field encoding
  , encodeField
  , encodeOptionalField
  , encodeRepeatedField
  , encodePackedField
    -- * Submessage encoding
  , encodeSubmessage
    -- * Tag encoding (for advanced use)
  , pbTag
    -- * Proto3 default values
  , ProtoDefault (..)
  ) where

import Data.Bits (shiftL, (.|.))
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Builder as B
import qualified Data.ByteString.Builder.Extra as BEx
import qualified Data.ByteString.Lazy as LBS
import Data.Int (Int32, Int64)
import Data.Text (Text)
import qualified Data.Text.Encoding as TE
import Data.Word (Word32, Word64)
import GHC.TypeLits (KnownNat, natVal)
import Data.Proxy (Proxy(..))

import Spire.Protobuf.Wire
import Spire.Protobuf.Field


-- ===================================================================
-- ProtoBuilder
-- ===================================================================

-- | A 'Builder' paired with its exact byte length.
--
-- This is needed for submessage encoding: protobuf length-delimited
-- fields require the byte count as a varint prefix. Tracking it
-- incrementally avoids a double-pass.
data ProtoBuilder = ProtoBuilder
  { pbLength  :: {-# UNPACK #-} !Int
  , pbBuilder :: !B.Builder
  }

instance Semigroup ProtoBuilder where
  ProtoBuilder l1 b1 <> ProtoBuilder l2 b2 =
    ProtoBuilder (l1 + l2) (b1 <> b2)
  {-# INLINE (<>) #-}

instance Monoid ProtoBuilder where
  mempty = ProtoBuilder 0 mempty
  {-# INLINE mempty #-}

-- | Empty builder.
pbEmpty :: ProtoBuilder
pbEmpty = mempty
{-# INLINE pbEmpty #-}

-- | Materialize a 'ProtoBuilder' into a strict 'ByteString'.
-- Uses 'safeStrategy' with the exact known length for a single allocation.
runProtoBuilder :: ProtoBuilder -> ByteString
runProtoBuilder (ProtoBuilder len bld) =
  LBS.toStrict (BEx.toLazyByteStringWith
    (BEx.safeStrategy len BEx.smallChunkSize)
    LBS.empty
    bld)
{-# INLINE runProtoBuilder #-}


-- ===================================================================
-- Helpers: wrap Wire primitives as ProtoBuilder
-- ===================================================================

-- | Encode a varint as a 'ProtoBuilder'.
pbVarint :: Word64 -> ProtoBuilder
pbVarint n = ProtoBuilder (varintSize n) (encodeVarint n)
{-# INLINE pbVarint #-}

-- | Number of bytes a varint takes on the wire.
varintSize :: Word64 -> Int
varintSize n
  | n < 0x80       = 1
  | n < 0x4000     = 2
  | n < 0x200000   = 3
  | n < 0x10000000 = 4
  | n < 0x0800000000 = 5
  | n < 0x040000000000 = 6
  | n < 0x02000000000000 = 7
  | n < 0x0100000000000000 = 8
  | n < 0x8000000000000000 = 9
  | otherwise = 10
{-# INLINE varintSize #-}

-- | Encode a tag as a 'ProtoBuilder'.
pbTag :: Int -> WireType -> ProtoBuilder
pbTag fieldNum wt =
  let tagVal = fromIntegral fieldNum `shiftL` 3 .|. wireTypeToWord wt
  in pbVarint tagVal
{-# INLINE pbTag #-}

wireTypeToWord :: WireType -> Word64
wireTypeToWord WireVarint          = 0
wireTypeToWord WireFixed64         = 1
wireTypeToWord WireLengthDelimited = 2
wireTypeToWord WireFixed32         = 5
{-# INLINE wireTypeToWord #-}

-- | Encode raw bytes as a 'ProtoBuilder'.
pbBytes :: ByteString -> ProtoBuilder
pbBytes bs = ProtoBuilder (BS.length bs) (B.byteString bs)
{-# INLINE pbBytes #-}


-- ===================================================================
-- ProtoEncode class
-- ===================================================================

-- | Class for types that can be encoded into protobuf wire format.
class ProtoEncode a where
  -- | The wire type used for this value.
  protoWireType :: WireType
  -- | Encode the value (without a tag prefix).
  protoEncodeValue :: a -> ProtoBuilder

-- Int32: varint encoding (cast to Word64 preserving bit pattern)
instance ProtoEncode Int32 where
  protoWireType = WireVarint
  protoEncodeValue n = pbVarint (fromIntegral n :: Word64)
  {-# INLINE protoEncodeValue #-}

-- Int64: varint encoding
instance ProtoEncode Int64 where
  protoWireType = WireVarint
  protoEncodeValue n = pbVarint (fromIntegral n :: Word64)
  {-# INLINE protoEncodeValue #-}

-- Word32: varint encoding
instance ProtoEncode Word32 where
  protoWireType = WireVarint
  protoEncodeValue n = pbVarint (fromIntegral n)
  {-# INLINE protoEncodeValue #-}

-- Word64: varint encoding
instance ProtoEncode Word64 where
  protoWireType = WireVarint
  protoEncodeValue = pbVarint
  {-# INLINE protoEncodeValue #-}

-- Bool: varint 0 or 1
instance ProtoEncode Bool where
  protoWireType = WireVarint
  protoEncodeValue b = pbVarint (if b then 1 else 0)
  {-# INLINE protoEncodeValue #-}

-- Float: fixed32
instance ProtoEncode Float where
  protoWireType = WireFixed32
  protoEncodeValue f = ProtoBuilder 4 (encodeProtoFloat f)
  {-# INLINE protoEncodeValue #-}

-- Double: fixed64
instance ProtoEncode Double where
  protoWireType = WireFixed64
  protoEncodeValue d = ProtoBuilder 8 (encodeProtoDouble d)
  {-# INLINE protoEncodeValue #-}

-- Text: length-delimited UTF-8
instance ProtoEncode Text where
  protoWireType = WireLengthDelimited
  protoEncodeValue t =
    let bs = TE.encodeUtf8 t
        len = BS.length bs
    in pbVarint (fromIntegral len) <> pbBytes bs
  {-# INLINE protoEncodeValue #-}

-- ByteString: length-delimited raw bytes
instance ProtoEncode ByteString where
  protoWireType = WireLengthDelimited
  protoEncodeValue bs =
    let len = BS.length bs
    in pbVarint (fromIntegral len) <> pbBytes bs
  {-# INLINE protoEncodeValue #-}

-- SInt32: varint with zigzag encoding
instance ProtoEncode SInt32 where
  protoWireType = WireVarint
  protoEncodeValue (SInt32 n) = pbVarint (zigzagEncode (fromIntegral n))
  {-# INLINE protoEncodeValue #-}

-- SInt64: varint with zigzag encoding
instance ProtoEncode SInt64 where
  protoWireType = WireVarint
  protoEncodeValue (SInt64 n) = pbVarint (zigzagEncode n)
  {-# INLINE protoEncodeValue #-}


-- ===================================================================
-- Submessage encoding
-- ===================================================================

-- | Encode a pre-built submessage as a length-delimited value.
-- Prepends the byte length as a varint.
encodeSubmessage :: ProtoBuilder -> ProtoBuilder
encodeSubmessage (ProtoBuilder len bld) =
  let lenPb = pbVarint (fromIntegral len)
  in lenPb <> ProtoBuilder len bld
{-# INLINE encodeSubmessage #-}


-- ===================================================================
-- Field encoding
-- ===================================================================

-- | Encode a single field: tag + value.
-- Proto3 semantics: skips the field if the value is the default.
encodeField :: forall n a. (KnownNat n, ProtoEncode a, Eq a, ProtoDefault a)
            => Field n a -> ProtoBuilder
encodeField (Field val)
  | val == protoDefault = mempty
  | otherwise =
    let fn = fromIntegral (natVal (Proxy @n)) :: Int
        tag = pbTag fn (protoWireType @a)
    in tag <> protoEncodeValue val
{-# INLINE encodeField #-}

-- | Encode an optional field ('Maybe' wrapper).
-- 'Nothing' encodes to zero bytes. 'Just' encodes like a regular field.
encodeOptionalField :: forall n a. (KnownNat n, ProtoEncode a)
                    => Field n (Maybe a) -> ProtoBuilder
encodeOptionalField (Field Nothing)  = mempty
encodeOptionalField (Field (Just val)) =
  let fn = fromIntegral (natVal (Proxy @n)) :: Int
      tag = pbTag fn (protoWireType @a)
  in tag <> protoEncodeValue val
{-# INLINE encodeOptionalField #-}

-- | Encode a repeated (non-packed) field: each element gets its own tag.
encodeRepeatedField :: forall n a. (KnownNat n, ProtoEncode a)
                    => Field n [a] -> ProtoBuilder
encodeRepeatedField (Field xs) =
  let fn = fromIntegral (natVal (Proxy @n)) :: Int
      tag = pbTag fn (protoWireType @a)
  in foldMap (\v -> tag <> protoEncodeValue v) xs
{-# INLINE encodeRepeatedField #-}

-- | Encode a packed repeated field: one length-delimited blob containing
-- all elements concatenated (only valid for scalar numeric types).
encodePackedField :: forall n a. (KnownNat n, ProtoEncode a)
                  => Field n [a] -> ProtoBuilder
encodePackedField (Field []) = mempty
encodePackedField (Field xs) =
  let fn = fromIntegral (natVal (Proxy @n)) :: Int
      tag = pbTag fn WireLengthDelimited
      content = foldMap protoEncodeValue xs
  in tag <> encodeSubmessage content
{-# INLINE encodePackedField #-}


-- ===================================================================
-- Proto3 default values
-- ===================================================================

-- | Default values for proto3 field types.
-- In proto3, fields with the default value are omitted from the wire.
class ProtoDefault a where
  protoDefault :: a

instance ProtoDefault Int32  where protoDefault = 0
instance ProtoDefault Int64  where protoDefault = 0
instance ProtoDefault Word32 where protoDefault = 0
instance ProtoDefault Word64 where protoDefault = 0
instance ProtoDefault Bool   where protoDefault = False
instance ProtoDefault Float  where protoDefault = 0.0
instance ProtoDefault Double where protoDefault = 0.0
instance ProtoDefault Text   where protoDefault = ""
instance ProtoDefault ByteString where protoDefault = BS.empty
instance ProtoDefault SInt32 where protoDefault = SInt32 0
instance ProtoDefault SInt64 where protoDefault = SInt64 0