packages feed

spire-protobuf (empty) → 0.1.0.0

raw patch · 12 files changed

+3219/−0 lines, 12 filesdep +basedep +bytestringdep +containers

Dependencies added: base, bytestring, containers, hedgehog, process, spire-protobuf, text

Files

+ CHANGELOG.md view
@@ -0,0 +1,7 @@+# Revision history for spire-protobuf++## 0.1.0.0 -- 2026-04-27++* Initial release. Minimal Protocol Buffers library with zero-copy+  decoding and GHC Generics integration. No code generation, no+  Template Haskell, no external tools.
+ LICENSE view
@@ -0,0 +1,27 @@+Copyright (c) 2024-2026, Josh Burgess++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++1. Redistributions of source code must retain the above copyright notice,+   this list of conditions and the following disclaimer.++2. Redistributions in binary form must reproduce the above copyright notice,+   this list of conditions and the following disclaimer in the documentation+   and/or other materials provided with the distribution.++3. Neither the name of the copyright holder nor the names of its+   contributors may be used to endorse or promote products derived from this+   software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE+POSSIBILITY OF SUCH DAMAGE.
+ spire-protobuf.cabal view
@@ -0,0 +1,108 @@+cabal-version:   3.0+name:            spire-protobuf+version:         0.1.0.0+synopsis:        Minimal Protocol Buffers for Haskell, no codegen+category:        Data, Codec+description:+  A fast, minimal protobuf library with zero-copy decoding and GHC+  Generics integration. No code generation, no Template Haskell, no+  external tools.+  .+  Define messages as Haskell records with 'Field' wrappers for field+  numbers, derive 'Generic' and 'ProtoMessage', and get encode/decode+  for free.+  .+  @+  data User = User+    { name  :: Field 1 Text+    , age   :: Field 2 Int32+    , email :: Field 3 (Maybe Text)+    } deriving (Generic, ProtoMessage)+  @+  .+  Key design decisions:+  .+  * Single-pass decoding directly into target type (no intermediate Map)+  * Zero-copy string\/bytes fields (ByteString slices into input buffer)+  * Length-memoizing Builder for correct submessage encoding+  * GHC 9.10 @-finline-generics@ eliminates Generic overhead+  * 4 dependencies: base, bytestring, text, containers++license:         BSD-3-Clause+license-file:    LICENSE+author:          Josh Burgess+maintainer:      Josh Burgess <joshburgess.webdev@gmail.com>+homepage:        https://github.com/joshburgess/acolyte+bug-reports:     https://github.com/joshburgess/acolyte/issues+build-type:      Simple++extra-source-files:+  test/test.proto++extra-doc-files:+  CHANGELOG.md++library+  exposed-modules:+    Spire.Protobuf+    Spire.Protobuf.Wire+    Spire.Protobuf.Encode+    Spire.Protobuf.Decode+    Spire.Protobuf.Field+    Spire.Protobuf.Message++  build-depends:+      base         >= 4.20 && < 5+    , bytestring   >= 0.11 && < 0.13+    , text         >= 2.0  && < 2.2+    , containers   >= 0.6  && < 0.8++  hs-source-dirs: src+  default-language: GHC2024+  default-extensions:+    StrictData+    OverloadedStrings++  ghc-options: -Wall -funbox-strict-fields -finline-generics++test-suite spec+  type: exitcode-stdio-1.0+  main-is: Main.hs+  hs-source-dirs: test+  default-language: GHC2024+  default-extensions:+    StrictData+    OverloadedStrings++  ghc-options: -Wall -rtsopts "-with-rtsopts=-K1K"++  build-depends:+      base         >= 4.20 && < 5+    , spire-protobuf+    , bytestring   >= 0.11 && < 0.13+    , text         >= 2.0  && < 2.2+    , containers   >= 0.6  && < 0.8+    , process      >= 1.6  && < 1.8++test-suite properties+  type: exitcode-stdio-1.0+  main-is: Properties.hs+  hs-source-dirs: test+  default-language: GHC2024+  default-extensions:+    StrictData+    OverloadedStrings++  ghc-options: -Wall++  build-depends:+      base         >= 4.20 && < 5+    , spire-protobuf+    , hedgehog     >= 1.4  && < 1.8+    , bytestring   >= 0.11 && < 0.13+    , text         >= 2.0  && < 2.2+    , containers   >= 0.6  && < 0.8++source-repository head+  type:     git+  location: https://github.com/joshburgess/acolyte.git
+ src/Spire/Protobuf.hs view
@@ -0,0 +1,52 @@+-- | High-performance Protocol Buffers for Haskell.+--+-- Define messages as Haskell records with 'Field' wrappers for field+-- numbers, derive 'Generic' and 'ProtoMessage', and get encode\/decode+-- for free:+--+-- @+-- data User = User+--   { name  :: Field 1 Text+--   , age   :: Field 2 Int32+--   , email :: Field 3 (Maybe Text)+--   } deriving (Generic)+--+-- instance ProtoMessage User+--+-- -- Encode:+-- let bs = encode (User (Field "Alice") (Field 30) (Field (Just "alice\@example.com")))+--+-- -- Decode:+-- case decode bs of+--   Right user -> print user+--   Left err   -> error (show err)+-- @+module Spire.Protobuf+  ( -- * Messages+    ProtoMessage (..)+  , encode+  , decode+    -- * Field wrapper+  , Field (..)+    -- * Signed integer wrappers (zigzag encoding)+  , SInt32 (..)+  , SInt64 (..)+    -- * Proto3 map fields+  , ProtoMap (..)+    -- * Errors+  , DecodeError (..)+    -- * Re-exports for advanced use+  , ProtoEncode (..)+  , ProtoDecode (..)+  , decodePacked+  , DecodeRepeatedEntry (..)+  , ProtoDefault (..)+  , ProtoBuilder+  , WireType (..)+  ) where++import Spire.Protobuf.Wire (WireType (..))+import Spire.Protobuf.Field (Field (..), SInt32(..), SInt64(..), ProtoMap(..))+import Spire.Protobuf.Encode (ProtoEncode (..), ProtoBuilder, ProtoDefault (..))+import Spire.Protobuf.Decode (ProtoDecode (..), DecodeError (..), decodePacked, DecodeRepeatedEntry(..))+import Spire.Protobuf.Message (ProtoMessage (..), encode, decode)
+ src/Spire/Protobuf/Decode.hs view
@@ -0,0 +1,325 @@+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE UndecidableInstances #-}++-- | Protobuf decoding: single-pass wire format parser.+--+-- Provides 'ProtoDecode' for individual value types and 'DecodeError'+-- for structured error reporting. The decoder works directly on strict+-- 'ByteString' with zero-copy slicing for bytes/string fields.+module Spire.Protobuf.Decode+  ( -- * ProtoDecode class+    ProtoDecode (..)+    -- * Raw field parsing+  , RawField (..)+  , parseFields+    -- * Packed repeated field decoding+  , decodePacked+    -- * Packed/unpacked repeated entry decoding (Generics support)+  , DecodeRepeatedEntry (..)+    -- * Errors+  , DecodeError (..)+  ) where++import Data.Bits ((.&.))+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import Data.Int (Int32, Int64)+import Data.Text (Text)+import qualified Data.Text.Encoding as TE+import Data.Word (Word32, Word64)+import GHC.Float (castWord32ToFloat, castWord64ToDouble)++import Spire.Protobuf.Wire+import Spire.Protobuf.Field (SInt32(..), SInt64(..))+++-- ===================================================================+-- Errors+-- ===================================================================++-- | Errors that can occur during protobuf decoding.+data DecodeError+  = UnexpectedWireType !Int !WireType !WireType+    -- ^ field number, expected wire type, actual wire type+  | TruncatedInput+    -- ^ input ended before a complete value could be read+  | InvalidVarint+    -- ^ varint encoding was malformed or overflowed+  | InvalidUtf8+    -- ^ a string field contained invalid UTF-8+  | InvalidWireType !Int+    -- ^ wire type id was not one of 0, 1, 2, 5+  deriving (Show, Eq)+++-- ===================================================================+-- Raw field representation+-- ===================================================================++-- | A raw (unparsed) field value as it appears on the wire.+-- Used as an intermediate representation during decoding: parse all+-- tag-value pairs first, then interpret each field.+data RawField+  = RawVarint   !Word64+  | RawFixed64  !Word64+  | RawFixed32  !Word32+  | RawBytes    !ByteString   -- ^ zero-copy slice of the input+  deriving (Show, Eq)+++-- | Parse all tag-value pairs from a protobuf message.+-- Returns a list of (field_number, raw_value) pairs in wire order.+-- Unknown wire types cause a 'Left' error; otherwise parsing is+-- lenient (unknown field numbers are preserved).+parseFields :: ByteString -> Either DecodeError [(Int, RawField)]+parseFields = go []+  where+    go !acc bs+      | BS.null bs = Right (reverse acc)+      | otherwise = case decodeTag bs of+          Nothing ->+            -- Distinguish truncated input from invalid wire type+            case decodeVarint bs of+              Nothing -> Left TruncatedInput+              Just (val, _) ->+                let wireId = fromIntegral (val .&. 0x07) :: Int+                in if wireId `elem` [0, 1, 2, 5]+                   then Left TruncatedInput+                   else Left (InvalidWireType wireId)+          Just (fieldNum, wt, rest) -> case parseRawField wt rest of+            Nothing  -> Left TruncatedInput+            Just (raw, rest') -> go ((fieldNum, raw) : acc) rest'++-- | Parse a raw field value given its wire type.+parseRawField :: WireType -> ByteString -> Maybe (RawField, ByteString)+parseRawField WireVarint bs = do+  (val, rest) <- decodeVarint bs+  Just (RawVarint val, rest)+parseRawField WireFixed64 bs = do+  (val, rest) <- decodeFixed64 bs+  Just (RawFixed64 val, rest)+parseRawField WireFixed32 bs = do+  (val, rest) <- decodeFixed32 bs+  Just (RawFixed32 val, rest)+parseRawField WireLengthDelimited bs = do+  (val, rest) <- decodeLengthDelimited bs+  Just (RawBytes val, rest)+++-- ===================================================================+-- ProtoDecode class+-- ===================================================================++-- | Class for types that can be decoded from a raw protobuf field.+class ProtoDecode a where+  -- | Decode a value from a 'RawField'.+  protoDecodeValue :: RawField -> Either DecodeError a++-- Int32: varint+instance ProtoDecode Int32 where+  protoDecodeValue (RawVarint w) = Right (fromIntegral w :: Int32)+  protoDecodeValue _             = Left (UnexpectedWireType 0 WireVarint WireLengthDelimited)+  {-# INLINE protoDecodeValue #-}++-- Int64: varint+instance ProtoDecode Int64 where+  protoDecodeValue (RawVarint w) = Right (fromIntegral w :: Int64)+  protoDecodeValue _             = Left (UnexpectedWireType 0 WireVarint WireLengthDelimited)+  {-# INLINE protoDecodeValue #-}++-- Word32: varint+instance ProtoDecode Word32 where+  protoDecodeValue (RawVarint w) = Right (fromIntegral w :: Word32)+  protoDecodeValue _             = Left (UnexpectedWireType 0 WireVarint WireLengthDelimited)+  {-# INLINE protoDecodeValue #-}++-- Word64: varint+instance ProtoDecode Word64 where+  protoDecodeValue (RawVarint w) = Right w+  protoDecodeValue _             = Left (UnexpectedWireType 0 WireVarint WireLengthDelimited)+  {-# INLINE protoDecodeValue #-}++-- Bool: varint, 0 = False, nonzero = True+instance ProtoDecode Bool where+  protoDecodeValue (RawVarint w) = Right (w /= 0)+  protoDecodeValue _             = Left (UnexpectedWireType 0 WireVarint WireLengthDelimited)+  {-# INLINE protoDecodeValue #-}++-- Float: fixed32+instance ProtoDecode Float where+  protoDecodeValue (RawFixed32 w) =+    Right (castWord32ToFloat w)+  protoDecodeValue _ = Left (UnexpectedWireType 0 WireFixed32 WireVarint)+  {-# INLINE protoDecodeValue #-}++-- Double: fixed64+instance ProtoDecode Double where+  protoDecodeValue (RawFixed64 w) =+    Right (castWord64ToDouble w)+  protoDecodeValue _ = Left (UnexpectedWireType 0 WireFixed64 WireVarint)+  {-# INLINE protoDecodeValue #-}++-- Text: length-delimited, UTF-8 decoded+instance ProtoDecode Text where+  protoDecodeValue (RawBytes bs) =+    case TE.decodeUtf8' bs of+      Left _  -> Left InvalidUtf8+      Right t -> Right t+  protoDecodeValue _ = Left (UnexpectedWireType 0 WireLengthDelimited WireVarint)+  {-# INLINE protoDecodeValue #-}++-- ByteString: length-delimited, zero-copy slice+instance ProtoDecode ByteString where+  protoDecodeValue (RawBytes bs) = Right bs+  protoDecodeValue _ = Left (UnexpectedWireType 0 WireLengthDelimited WireVarint)+  {-# INLINE protoDecodeValue #-}++-- SInt32: varint with zigzag decoding+instance ProtoDecode SInt32 where+  protoDecodeValue (RawVarint w) =+    Right (SInt32 (fromIntegral (zigzagDecode w)))+  protoDecodeValue _ = Left (UnexpectedWireType 0 WireVarint WireLengthDelimited)+  {-# INLINE protoDecodeValue #-}++-- SInt64: varint with zigzag decoding+instance ProtoDecode SInt64 where+  protoDecodeValue (RawVarint w) =+    Right (SInt64 (zigzagDecode w))+  protoDecodeValue _ = Left (UnexpectedWireType 0 WireVarint WireLengthDelimited)+  {-# INLINE protoDecodeValue #-}+++-- ===================================================================+-- Packed repeated field decoding+-- ===================================================================++-- | Decode packed repeated scalar values from a 'RawBytes' blob.+-- In proto3, repeated scalar fields may be encoded as a single+-- length-delimited blob containing all values concatenated.+-- Returns 'Left' if the blob cannot be fully consumed.+decodePacked :: ProtoDecode a => (ByteString -> Maybe (a, ByteString)) -> RawField -> Either DecodeError [a]+decodePacked parser (RawBytes bs) = go bs []+  where+    go remaining acc+      | BS.null remaining = Right (reverse acc)+      | otherwise = case parser remaining of+          Just (val, rest) -> go rest (val : acc)+          Nothing -> Left TruncatedInput+decodePacked _ _ = Left (UnexpectedWireType 0 WireLengthDelimited WireVarint)+++-- ===================================================================+-- DecodeRepeatedEntry: handles both packed and unpacked entries+-- ===================================================================++-- | Decode a single raw field entry as one or more values of type @a@.+--+-- For packable scalar types (Int32, Int64, Word32, Word64, Bool, Float,+-- Double, SInt32, SInt64), a 'RawBytes' entry is treated as a packed+-- blob containing multiple concatenated values. For non-packable types+-- (Text, ByteString, submessages), 'RawBytes' is decoded as a single+-- value.+--+-- This class is used by the Generics decoder for repeated fields to+-- transparently handle both packed and unpacked wire encodings.+class DecodeRepeatedEntry a where+  decodeRepeatedEntry :: RawField -> Either DecodeError [a]++-- | Default: non-packable types. Decode as a single value.+instance {-# OVERLAPPABLE #-} ProtoDecode a => DecodeRepeatedEntry a where+  decodeRepeatedEntry raw = (: []) <$> protoDecodeValue raw+  {-# INLINE decodeRepeatedEntry #-}++-- Varint-based packable scalars: Int32, Int64, Word32, Word64, Bool++instance DecodeRepeatedEntry Int32 where+  decodeRepeatedEntry (RawBytes bs) = unpackVarints bs+  decodeRepeatedEntry raw = (: []) <$> protoDecodeValue raw+  {-# INLINE decodeRepeatedEntry #-}++instance DecodeRepeatedEntry Int64 where+  decodeRepeatedEntry (RawBytes bs) = unpackVarints bs+  decodeRepeatedEntry raw = (: []) <$> protoDecodeValue raw+  {-# INLINE decodeRepeatedEntry #-}++instance DecodeRepeatedEntry Word32 where+  decodeRepeatedEntry (RawBytes bs) = unpackVarints bs+  decodeRepeatedEntry raw = (: []) <$> protoDecodeValue raw+  {-# INLINE decodeRepeatedEntry #-}++instance DecodeRepeatedEntry Word64 where+  decodeRepeatedEntry (RawBytes bs) = unpackVarints bs+  decodeRepeatedEntry raw = (: []) <$> protoDecodeValue raw+  {-# INLINE decodeRepeatedEntry #-}++instance DecodeRepeatedEntry Bool where+  decodeRepeatedEntry (RawBytes bs) = unpackVarints bs+  decodeRepeatedEntry raw = (: []) <$> protoDecodeValue raw+  {-# INLINE decodeRepeatedEntry #-}++-- Fixed32-based: Float+instance DecodeRepeatedEntry Float where+  decodeRepeatedEntry (RawBytes bs) = unpackFixed32s bs+  decodeRepeatedEntry raw = (: []) <$> protoDecodeValue raw+  {-# INLINE decodeRepeatedEntry #-}++-- Fixed64-based: Double+instance DecodeRepeatedEntry Double where+  decodeRepeatedEntry (RawBytes bs) = unpackFixed64s bs+  decodeRepeatedEntry raw = (: []) <$> protoDecodeValue raw+  {-# INLINE decodeRepeatedEntry #-}++-- Varint-based with zigzag: SInt32, SInt64+instance DecodeRepeatedEntry SInt32 where+  decodeRepeatedEntry (RawBytes bs) = unpackVarints bs+  decodeRepeatedEntry raw = (: []) <$> protoDecodeValue raw+  {-# INLINE decodeRepeatedEntry #-}++instance DecodeRepeatedEntry SInt64 where+  decodeRepeatedEntry (RawBytes bs) = unpackVarints bs+  decodeRepeatedEntry raw = (: []) <$> protoDecodeValue raw+  {-# INLINE decodeRepeatedEntry #-}+++-- ===================================================================+-- Unpacking helpers+-- ===================================================================++-- | Unpack varints from a packed blob, decoding each via 'ProtoDecode'.+unpackVarints :: ProtoDecode a => ByteString -> Either DecodeError [a]+unpackVarints = go []+  where+    go !acc bs+      | BS.null bs = Right (reverse acc)+      | otherwise = case decodeVarint bs of+          Just (w, rest) -> case protoDecodeValue (RawVarint w) of+            Right val -> go (val : acc) rest+            Left err  -> Left err+          Nothing -> Left TruncatedInput+{-# INLINE unpackVarints #-}++-- | Unpack fixed32 values from a packed blob.+unpackFixed32s :: ProtoDecode a => ByteString -> Either DecodeError [a]+unpackFixed32s = go []+  where+    go !acc bs+      | BS.null bs = Right (reverse acc)+      | otherwise = case decodeFixed32 bs of+          Just (w, rest) -> case protoDecodeValue (RawFixed32 w) of+            Right val -> go (val : acc) rest+            Left err  -> Left err+          Nothing -> Left TruncatedInput+{-# INLINE unpackFixed32s #-}++-- | Unpack fixed64 values from a packed blob.+unpackFixed64s :: ProtoDecode a => ByteString -> Either DecodeError [a]+unpackFixed64s = go []+  where+    go !acc bs+      | BS.null bs = Right (reverse acc)+      | otherwise = case decodeFixed64 bs of+          Just (w, rest) -> case protoDecodeValue (RawFixed64 w) of+            Right val -> go (val : acc) rest+            Left err  -> Left err+          Nothing -> Left TruncatedInput+{-# INLINE unpackFixed64s #-}
+ src/Spire/Protobuf/Encode.hs view
@@ -0,0 +1,293 @@+{-# 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
+ src/Spire/Protobuf/Field.hs view
@@ -0,0 +1,67 @@+-- | Type-level field numbers for protobuf messages.+--+-- The 'Field' newtype embeds a compile-time field number (proto3 field tag)+-- into the type. At runtime it is a zero-cost wrapper (via 'coerce').+--+-- @+-- data User = User+--   { name :: Field 1 Text+--   , age  :: Field 2 Int32+--   }+-- @+module Spire.Protobuf.Field+  ( Field (..)+  , fieldVal+    -- * Signed integer wrappers (zigzag encoding)+  , SInt32 (..)+  , SInt64 (..)+    -- * Proto3 map fields+  , ProtoMap (..)+  ) where++import Data.Int (Int32, Int64)+import Data.Map.Strict (Map)+import GHC.TypeLits (Nat, KnownNat, natVal)+import Data.Proxy (Proxy(..))++-- | A protobuf field with a compile-time field number.+--+-- The field number is used for wire encoding; at runtime, 'Field' is+-- just a newtype wrapper (zero overhead via coerce).+newtype Field (n :: Nat) a = Field { unField :: a }+  deriving (Show, Eq, Ord, Functor)++-- | Get the field number at runtime.+fieldVal :: forall n a. KnownNat n => Field n a -> Int+fieldVal _ = fromIntegral (natVal (Proxy @n))+{-# INLINE fieldVal #-}+++-- | Signed 32-bit integer using zigzag encoding on the wire.+-- Use for fields declared as @sint32@ in .proto files.+-- Zigzag encoding maps small-magnitude values (positive or negative)+-- to small varints, unlike plain @int32@ which uses 10 bytes for negatives.+newtype SInt32 = SInt32 { unSInt32 :: Int32 }+  deriving (Show, Eq, Ord)++-- | Signed 64-bit integer using zigzag encoding on the wire.+-- Use for fields declared as @sint64@ in .proto files.+newtype SInt64 = SInt64 { unSInt64 :: Int64 }+  deriving (Show, Eq, Ord)+++-- | Proto3 map field: @map\<K, V\>@.+--+-- On the wire, a map is encoded as a repeated message where each entry+-- has field 1 = key and field 2 = value. This newtype wraps 'Map' and+-- provides the correct protobuf encoding/decoding.+--+-- @+-- data Config = Config+--   { settings :: Field 1 (ProtoMap Text Text)+--   , counts   :: Field 2 (ProtoMap Text Int32)+--   } deriving (Generic)+-- instance ProtoMessage Config+-- @+newtype ProtoMap k v = ProtoMap { unProtoMap :: Map k v }+  deriving (Show, Eq, Ord)
+ src/Spire/Protobuf/Message.hs view
@@ -0,0 +1,305 @@+{-# LANGUAGE UndecidableInstances #-}+{-# LANGUAGE TypeFamilies #-}+{-# LANGUAGE AllowAmbiguousTypes #-}+{-# LANGUAGE DefaultSignatures #-}+{-# OPTIONS_GHC -Wno-orphans #-}++-- | GHC Generics integration for automatic protobuf encoding/decoding.+--+-- Define your message as a Haskell record with 'Field' wrappers,+-- derive 'Generic', and write an empty 'ProtoMessage' instance:+--+-- @+-- data User = User+--   { name  :: Field 1 Text+--   , age   :: Field 2 Int32+--   , email :: Field 3 (Maybe Text)+--   } deriving (Generic)+--+-- instance ProtoMessage User+-- @+--+-- Encoding and decoding are then available via 'encode' and 'decode'.+module Spire.Protobuf.Message+  ( ProtoMessage (..)+  , encode+  , decode+  , GProtoEncode (..)+  , GProtoDecode (..)+  ) where++import Data.ByteString (ByteString)+import qualified Data.Map.Strict as Map+import GHC.Generics+import GHC.TypeLits (KnownNat, natVal)+import Data.Proxy (Proxy(..))++import Spire.Protobuf.Wire+import Spire.Protobuf.Field+import Spire.Protobuf.Encode+import Spire.Protobuf.Decode+++-- ===================================================================+-- ProtoMessage class+-- ===================================================================++-- | Class for protobuf messages that can be encoded and decoded.+--+-- The default implementations use GHC Generics to walk the record+-- structure. Each field must be wrapped in 'Field' with a unique+-- field number.+class ProtoMessage a where+  protoEncode :: a -> ProtoBuilder+  default protoEncode :: (Generic a, GProtoEncode (Rep a)) => a -> ProtoBuilder+  protoEncode = gProtoEncode . from++  protoDecode :: ByteString -> Either DecodeError a+  default protoDecode :: (Generic a, GProtoDecode (Rep a)) => ByteString -> Either DecodeError a+  protoDecode bs = do+    rawFields <- parseFields bs+    to <$> gProtoDecode rawFields++-- | Encode a message to a strict 'ByteString'.+encode :: ProtoMessage a => a -> ByteString+encode = runProtoBuilder . protoEncode+{-# INLINE encode #-}++-- | Decode a message from a strict 'ByteString'.+decode :: ProtoMessage a => ByteString -> Either DecodeError a+decode = protoDecode+{-# INLINE decode #-}++-- ProtoMessage instance for submessage encoding+instance {-# OVERLAPPABLE #-} ProtoMessage a => ProtoEncode a where+  protoWireType = WireLengthDelimited+  protoEncodeValue a = encodeSubmessage (protoEncode a)+  {-# INLINE protoEncodeValue #-}++-- ProtoMessage instance for submessage decoding+instance {-# OVERLAPPABLE #-} ProtoMessage a => ProtoDecode a where+  protoDecodeValue (RawBytes bs) = protoDecode bs+  protoDecodeValue _             = Left (UnexpectedWireType 0 WireLengthDelimited WireVarint)+  {-# INLINE protoDecodeValue #-}+++-- ===================================================================+-- GProtoEncode: Generic encoding+-- ===================================================================++-- | Generic encoding class. Walks the 'Rep' of a type and encodes+-- each 'Field n a' it encounters.+class GProtoEncode f where+  gProtoEncode :: f p -> ProtoBuilder++-- M1: metadata wrappers (datatype, constructor, selector) — pass through+instance GProtoEncode f => GProtoEncode (M1 i c f) where+  gProtoEncode (M1 x) = gProtoEncode x+  {-# INLINE gProtoEncode #-}++-- :*: product — encode both sides+instance (GProtoEncode f, GProtoEncode g) => GProtoEncode (f :*: g) where+  gProtoEncode (f :*: g) = gProtoEncode f <> gProtoEncode g+  {-# INLINE gProtoEncode #-}++-- U1: unit (empty record)+instance GProtoEncode U1 where+  gProtoEncode U1 = mempty+  {-# INLINE gProtoEncode #-}++-- K1: a field of type Field n a — encode it+instance (KnownNat n, ProtoEncode a, Eq a, ProtoDefault a)+  => GProtoEncode (K1 i (Field n a)) where+  gProtoEncode (K1 fld) = encodeField fld+  {-# INLINE gProtoEncode #-}++-- K1: optional field — Field n (Maybe a)+instance {-# OVERLAPPING #-} (KnownNat n, ProtoEncode a)+  => GProtoEncode (K1 i (Field n (Maybe a))) where+  gProtoEncode (K1 fld) = encodeOptionalField fld+  {-# INLINE gProtoEncode #-}++-- K1: repeated field — Field n [a]+instance {-# OVERLAPPING #-} (KnownNat n, ProtoEncode a)+  => GProtoEncode (K1 i (Field n [a])) where+  gProtoEncode (K1 fld) = encodeRepeatedField fld+  {-# INLINE gProtoEncode #-}+++-- ===================================================================+-- GProtoDecode: Generic decoding+-- ===================================================================++-- | Generic decoding class. Constructs the 'Rep' of a type from a+-- list of raw (field_number, value) pairs.+--+-- The approach: parse all wire fields into @[(Int, RawField)]@ up front,+-- then for each record field, look up its field number in the list.+-- Unknown fields are silently ignored (proto3 forward-compatibility).+-- Last-wins semantics for duplicate field numbers.+class GProtoDecode f where+  gProtoDecode :: [(Int, RawField)] -> Either DecodeError (f p)++-- M1: metadata wrappers — pass through+instance GProtoDecode f => GProtoDecode (M1 i c f) where+  gProtoDecode rfs = M1 <$> gProtoDecode rfs+  {-# INLINE gProtoDecode #-}++-- :*: product — decode both sides from the same field list+instance (GProtoDecode f, GProtoDecode g) => GProtoDecode (f :*: g) where+  gProtoDecode rfs = do+    l <- gProtoDecode rfs+    r <- gProtoDecode rfs+    Right (l :*: r)+  {-# INLINE gProtoDecode #-}++-- U1: unit+instance GProtoDecode U1 where+  gProtoDecode _ = Right U1+  {-# INLINE gProtoDecode #-}++-- K1: required field — Field n a. Look up field number n; if absent, use default.+instance (KnownNat n, ProtoDecode a, ProtoDefault a)+  => GProtoDecode (K1 i (Field n a)) where+  gProtoDecode rfs =+    let fn = fromIntegral (natVal (Proxy @n))+    in case lookupLast fn rfs of+         Nothing  -> Right (K1 (Field protoDefault))+         Just raw -> K1 . Field <$> protoDecodeValue raw+  {-# INLINE gProtoDecode #-}++-- K1: optional field — Field n (Maybe a). Absent means Nothing.+instance {-# OVERLAPPING #-} (KnownNat n, ProtoDecode a)+  => GProtoDecode (K1 i (Field n (Maybe a))) where+  gProtoDecode rfs =+    let fn = fromIntegral (natVal (Proxy @n))+    in case lookupLast fn rfs of+         Nothing  -> Right (K1 (Field Nothing))+         Just raw -> K1 . Field . Just <$> protoDecodeValue raw+  {-# INLINE gProtoDecode #-}++-- K1: repeated field — Field n [a]. Collect all occurrences.+-- Handles both unpacked (each element tagged separately) and packed+-- (all elements in a single length-delimited blob) wire encodings.+-- For packable scalar types (Int32, Int64, Word32, Word64, Bool, Float,+-- Double, SInt32, SInt64), a RawBytes entry is automatically unpacked+-- into multiple values. For non-packable types (Text, ByteString,+-- submessages), each RawBytes is decoded as a single value.+instance {-# OVERLAPPING #-} (KnownNat n, DecodeRepeatedEntry a)+  => GProtoDecode (K1 i (Field n [a])) where+  gProtoDecode rfs =+    let fn = fromIntegral (natVal (Proxy @n))+        raws = [raw | (n', raw) <- rfs, n' == fn]+    in K1 . Field . concat <$> traverse decodeRepeatedEntry raws+  {-# INLINE gProtoDecode #-}+++-- ===================================================================+-- Helpers+-- ===================================================================++-- | Look up the last occurrence of a field number (last-wins proto3 semantics).+lookupLast :: Int -> [(Int, RawField)] -> Maybe RawField+lookupLast fn = go Nothing+  where+    go acc [] = acc+    go acc ((n, v) : rest)+      | n == fn   = go (Just v) rest+      | otherwise = go acc rest+{-# INLINE lookupLast #-}+++-- ===================================================================+-- ProtoMap: Proto3 map field support+-- ===================================================================++-- | Proto3 default for ProtoMap: empty map.+instance ProtoDefault (ProtoMap k v) where+  protoDefault = ProtoMap Map.empty++-- | Encode a ProtoMap as repeated length-delimited entries.+-- Each map entry is a submessage with field 1 = key, field 2 = value.+instance (Ord k, ProtoEncode k, ProtoEncode v) => ProtoEncode (ProtoMap k v) where+  protoWireType = WireLengthDelimited+  protoEncodeValue (ProtoMap m) =+    let encodeEntry k v =+          let keyEnc   = pbTag 1 (protoWireType @k) <> protoEncodeValue k+              valEnc   = pbTag 2 (protoWireType @v) <> protoEncodeValue v+              entryBody = keyEnc <> valEnc+          in encodeSubmessage entryBody+    in Map.foldlWithKey' (\acc k v -> acc <> encodeEntry k v) mempty m+  {-# INLINE protoEncodeValue #-}++-- | Decode a ProtoMap entry from a single RawBytes (one key-value pair submessage).+-- The caller (repeated field decoder) will collect all entries.+instance (Ord k, ProtoDecode k, ProtoDefault k, ProtoDecode v, ProtoDefault v)+  => ProtoDecode (ProtoMap k v) where+  protoDecodeValue (RawBytes bs) = do+    -- Parse the entry submessage+    rfs <- parseFields bs+    -- Extract key (field 1) and value (field 2), using defaults if absent+    k <- case lookupLast 1 rfs of+           Nothing  -> Right protoDefault+           Just raw -> protoDecodeValue raw+    v <- case lookupLast 2 rfs of+           Nothing  -> Right protoDefault+           Just raw -> protoDecodeValue raw+    Right (ProtoMap (Map.singleton k v))+  protoDecodeValue _ = Left (UnexpectedWireType 0 WireLengthDelimited WireVarint)+  {-# INLINE protoDecodeValue #-}++-- | DecodeRepeatedEntry for ProtoMap: each RawBytes is a single entry.+instance (Ord k, ProtoDecode k, ProtoDefault k, ProtoDecode v, ProtoDefault v)+  => DecodeRepeatedEntry (ProtoMap k v) where+  decodeRepeatedEntry raw = (: []) <$> protoDecodeValue raw+  {-# INLINE decodeRepeatedEntry #-}+++-- ===================================================================+-- ProtoMap Generics encoding/decoding+-- ===================================================================++-- K1: ProtoMap field — Field n (ProtoMap k v). Encode all entries as+-- repeated submessages with the same field number.+instance {-# OVERLAPPING #-}+  (KnownNat n, Ord k, ProtoEncode k, ProtoEncode v)+  => GProtoEncode (K1 i (Field n (ProtoMap k v))) where+  gProtoEncode (K1 (Field (ProtoMap m)))+    | Map.null m = mempty+    | otherwise =+      let fn = fromIntegral (natVal (Proxy @n)) :: Int+          tag = pbTag fn WireLengthDelimited+          encodeEntry k v =+            let keyEnc   = pbTag 1 (protoWireType @k) <> protoEncodeValue k+                valEnc   = pbTag 2 (protoWireType @v) <> protoEncodeValue v+                entryBody = keyEnc <> valEnc+            in tag <> encodeSubmessage entryBody+      in Map.foldlWithKey' (\acc k v -> acc <> encodeEntry k v) mempty m+  {-# INLINE gProtoEncode #-}++-- K1: ProtoMap field — Field n (ProtoMap k v). Decode by collecting all+-- repeated submessage entries for the field number and merging them.+instance {-# OVERLAPPING #-}+  (KnownNat n, Ord k, ProtoDecode k, ProtoDefault k, ProtoDecode v, ProtoDefault v)+  => GProtoDecode (K1 i (Field n (ProtoMap k v))) where+  gProtoDecode rfs =+    let fn = fromIntegral (natVal (Proxy @n))+        raws = [raw | (n', raw) <- rfs, n' == fn]+    in do+      entries <- traverse decodeMapEntry raws+      Right (K1 (Field (ProtoMap (Map.unions entries))))+  {-# INLINE gProtoDecode #-}++-- | Decode a single map entry submessage into a singleton Map.+decodeMapEntry :: (Ord k, ProtoDecode k, ProtoDefault k, ProtoDecode v, ProtoDefault v)+               => RawField -> Either DecodeError (Map.Map k v)+decodeMapEntry (RawBytes bs) = do+  rfs <- parseFields bs+  k <- case lookupLast 1 rfs of+         Nothing  -> Right protoDefault+         Just raw -> protoDecodeValue raw+  v <- case lookupLast 2 rfs of+         Nothing  -> Right protoDefault+         Just raw -> protoDecodeValue raw+  Right (Map.singleton k v)+decodeMapEntry _ = Left (UnexpectedWireType 0 WireLengthDelimited WireVarint)
+ src/Spire/Protobuf/Wire.hs view
@@ -0,0 +1,249 @@+-- | Protobuf wire format primitives.+--+-- The proto3 wire format uses 5 wire types. Each field on the wire is+-- a tag (field_number << 3 | wire_type) followed by the value.+--+-- This module provides low-level encoding and decoding of varints,+-- fixed-width values, and length-delimited fields.+module Spire.Protobuf.Wire+  ( -- * Wire types+    WireType (..)+    -- * Tags+  , encodeTag+  , decodeTag+    -- * Varint encoding/decoding+  , encodeVarint+  , decodeVarint+    -- * Zigzag encoding (for sint32/sint64)+  , zigzagEncode+  , zigzagDecode+    -- * Fixed-width encoding+  , encodeFixed32+  , encodeFixed64+  , decodeFixed32+  , decodeFixed64+    -- * Float/Double encoding+  , encodeProtoFloat+  , encodeProtoDouble+  , decodeProtoFloat+  , decodeProtoDouble+    -- * Length-delimited+  , encodeLengthDelimited+  , decodeLengthDelimited+    -- * Field skipping+  , skipField+  ) where++import Prelude hiding (encodeFloat, decodeFloat)+import Data.Bits+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Builder as B+import Data.Int (Int64)+import Data.Word (Word8, Word32, Word64)+import GHC.Float (castWord32ToFloat, castFloatToWord32,+                  castWord64ToDouble, castDoubleToWord64)+++-- | Proto3 wire types.+data WireType+  = WireVarint          -- ^ 0: int32, int64, uint32, uint64, sint32, sint64, bool, enum+  | WireFixed64         -- ^ 1: fixed64, sfixed64, double+  | WireLengthDelimited -- ^ 2: string, bytes, embedded messages, packed repeated+  | WireFixed32         -- ^ 5: fixed32, sfixed32, float+  deriving (Show, Eq)++wireTypeId :: WireType -> Word8+wireTypeId WireVarint          = 0+wireTypeId WireFixed64         = 1+wireTypeId WireLengthDelimited = 2+wireTypeId WireFixed32         = 5++wireTypeFromId :: Word8 -> Maybe WireType+wireTypeFromId 0 = Just WireVarint+wireTypeFromId 1 = Just WireFixed64+wireTypeFromId 2 = Just WireLengthDelimited+wireTypeFromId 5 = Just WireFixed32+wireTypeFromId _ = Nothing+++-- ===================================================================+-- Tags+-- ===================================================================++-- | Encode a field tag (field_number << 3 | wire_type).+encodeTag :: Int -> WireType -> B.Builder+encodeTag fieldNum wt =+  encodeVarint (fromIntegral fieldNum `shiftL` 3 .|. fromIntegral (wireTypeId wt))+{-# INLINE encodeTag #-}++-- | Decode a field tag. Returns (field_number, wire_type, remaining bytes).+decodeTag :: ByteString -> Maybe (Int, WireType, ByteString)+decodeTag bs = do+  (val, rest) <- decodeVarint bs+  let wireId = fromIntegral (val .&. 0x07) :: Word8+      fieldNum = fromIntegral (val `shiftR` 3)+  wt <- wireTypeFromId wireId+  Just (fieldNum, wt, rest)+{-# INLINE decodeTag #-}+++-- ===================================================================+-- Varint encoding/decoding+-- ===================================================================++-- | Encode a value as a varint (7 bits per byte, MSB continuation).+encodeVarint :: Word64 -> B.Builder+encodeVarint n+  | n < 0x80  = B.word8 (fromIntegral n)+  | otherwise = B.word8 (fromIntegral (n .&. 0x7F) .|. 0x80)+                <> encodeVarint (n `shiftR` 7)+{-# INLINE encodeVarint #-}++-- | Decode a varint. Returns (value, remaining bytes).+-- Rejects overlong encodings (more than 10 bytes or invalid high bits+-- in the 10th byte).+decodeVarint :: ByteString -> Maybe (Word64, ByteString)+decodeVarint = go 0 0+  where+    go !acc !shft !bs = case BS.uncons bs of+      Nothing -> Nothing+      Just (b, rest) ->+        let val = acc .|. (fromIntegral (b .&. 0x7F) `shiftL` shft)+        in if b .&. 0x80 == 0+           then if shft == 63 && (b .&. 0x7E) /= 0+                then Nothing  -- reject overlong encoding at byte 10+                else Just (val, rest)+           else if shft >= 63+                then Nothing  -- overflow protection+                else go val (shft + 7) rest+{-# INLINE decodeVarint #-}+++-- ===================================================================+-- Zigzag encoding (for sint32/sint64)+-- ===================================================================++-- | Zigzag-encode a signed integer: maps negatives to odds, positives to evens.+-- This makes small negative numbers small on the wire.+zigzagEncode :: Int64 -> Word64+zigzagEncode n = fromIntegral ((n `shiftL` 1) `xor` (n `shiftR` 63))+{-# INLINE zigzagEncode #-}++-- | Zigzag-decode.+zigzagDecode :: Word64 -> Int64+zigzagDecode n = fromIntegral ((n `shiftR` 1) `xor` negate (n .&. 1))+{-# INLINE zigzagDecode #-}+++-- ===================================================================+-- Fixed-width encoding+-- ===================================================================++-- | Encode a 32-bit value in little-endian.+encodeFixed32 :: Word32 -> B.Builder+encodeFixed32 = B.word32LE+{-# INLINE encodeFixed32 #-}++-- | Encode a 64-bit value in little-endian.+encodeFixed64 :: Word64 -> B.Builder+encodeFixed64 = B.word64LE+{-# INLINE encodeFixed64 #-}++-- | Decode a 32-bit little-endian value.+decodeFixed32 :: ByteString -> Maybe (Word32, ByteString)+decodeFixed32 bs+  | BS.length bs < 4 = Nothing+  | otherwise =+    let b0 = fromIntegral (BS.index bs 0) :: Word32+        b1 = fromIntegral (BS.index bs 1) :: Word32+        b2 = fromIntegral (BS.index bs 2) :: Word32+        b3 = fromIntegral (BS.index bs 3) :: Word32+    in Just (b0 .|. (b1 `shiftL` 8) .|. (b2 `shiftL` 16) .|. (b3 `shiftL` 24),+             BS.drop 4 bs)+{-# INLINE decodeFixed32 #-}++-- | Decode a 64-bit little-endian value.+decodeFixed64 :: ByteString -> Maybe (Word64, ByteString)+decodeFixed64 bs+  | BS.length bs < 8 = Nothing+  | otherwise =+    let readByte i = fromIntegral (BS.index bs i) :: Word64+    in Just ( readByte 0 .|. (readByte 1 `shiftL` 8) .|. (readByte 2 `shiftL` 16)+              .|. (readByte 3 `shiftL` 24) .|. (readByte 4 `shiftL` 32)+              .|. (readByte 5 `shiftL` 40) .|. (readByte 6 `shiftL` 48)+              .|. (readByte 7 `shiftL` 56),+              BS.drop 8 bs)+{-# INLINE decodeFixed64 #-}+++-- ===================================================================+-- Float/Double+-- ===================================================================++-- | Encode a Float (as fixed32, little-endian IEEE 754).+encodeProtoFloat :: Float -> B.Builder+encodeProtoFloat = encodeFixed32 . castFloatToWord32+{-# INLINE encodeProtoFloat #-}++-- | Encode a Double (as fixed64, little-endian IEEE 754).+encodeProtoDouble :: Double -> B.Builder+encodeProtoDouble = encodeFixed64 . castDoubleToWord64+{-# INLINE encodeProtoDouble #-}++-- | Decode a Float.+decodeProtoFloat :: ByteString -> Maybe (Float, ByteString)+decodeProtoFloat bs = do+  (w, rest) <- decodeFixed32 bs+  Just (castWord32ToFloat w, rest)+{-# INLINE decodeProtoFloat #-}++-- | Decode a Double.+decodeProtoDouble :: ByteString -> Maybe (Double, ByteString)+decodeProtoDouble bs = do+  (w, rest) <- decodeFixed64 bs+  Just (castWord64ToDouble w, rest)+{-# INLINE decodeProtoDouble #-}+++-- ===================================================================+-- Length-delimited+-- ===================================================================++-- | Encode a length-delimited value: varint length + bytes.+encodeLengthDelimited :: ByteString -> B.Builder+encodeLengthDelimited bs =+  encodeVarint (fromIntegral (BS.length bs)) <> B.byteString bs+{-# INLINE encodeLengthDelimited #-}++-- | Decode a length-delimited value. Returns (bytes, remaining).+-- Zero-copy: returns a slice of the input ByteString.+decodeLengthDelimited :: ByteString -> Maybe (ByteString, ByteString)+decodeLengthDelimited bs = do+  (len, rest) <- decodeVarint bs+  let n = fromIntegral len+  if BS.length rest < n+    then Nothing+    else Just (BS.take n rest, BS.drop n rest)+{-# INLINE decodeLengthDelimited #-}+++-- ===================================================================+-- Field skipping (for unknown fields)+-- ===================================================================++-- | Skip a field value based on its wire type. Returns remaining bytes.+skipField :: WireType -> ByteString -> Maybe ByteString+skipField WireVarint bs = do+  (_, rest) <- decodeVarint bs+  Just rest+skipField WireFixed64 bs+  | BS.length bs >= 8 = Just (BS.drop 8 bs)+  | otherwise = Nothing+skipField WireLengthDelimited bs = do+  (_, rest) <- decodeLengthDelimited bs+  Just rest+skipField WireFixed32 bs+  | BS.length bs >= 4 = Just (BS.drop 4 bs)+  | otherwise = Nothing+{-# INLINE skipField #-}
+ test/Main.hs view
@@ -0,0 +1,1511 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE UndecidableInstances #-}++module Main (main) where++import Control.Exception (try, SomeException)+import Data.ByteString (ByteString)+import qualified Data.ByteString as BS+import Data.Either (isRight)+import Data.Int (Int32, Int64)+import Data.List (isInfixOf)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import qualified Data.Text as T+import Data.Word (Word32, Word64)+import GHC.Generics (Generic)+import System.IO (hSetBinaryMode, hFlush, hClose, hGetContents)+import System.Process (proc, createProcess, waitForProcess,+                       readProcess, StdStream(..), std_in, std_out, std_err)++import Spire.Protobuf+import Spire.Protobuf.Wire+import Spire.Protobuf.Encode+import Spire.Protobuf.Decode+++-- ===================================================================+-- Test helpers+-- ===================================================================++assert :: String -> Bool -> IO ()+assert label True  = putStrLn $ "  OK: " ++ label+assert label False = error   $ "FAIL: " ++ label++-- | Helper: build a ByteString from a Builder-producing function+buildBS :: ProtoBuilder -> ByteString+buildBS = runProtoBuilder+++-- ===================================================================+-- Test message types+-- ===================================================================++data Simple = Simple+  { sName :: Field 1 Text+  , sAge  :: Field 2 Int32+  } deriving (Show, Eq, Generic)++instance ProtoMessage Simple++data WithOpt = WithOpt+  { wName :: Field 1 Text+  , wNick :: Field 2 (Maybe Text)+  } deriving (Show, Eq, Generic)++instance ProtoMessage WithOpt++data WithList = WithList+  { wTags :: Field 1 [Text]+  } deriving (Show, Eq, Generic)++instance ProtoMessage WithList++data AllDefaults = AllDefaults+  { dName :: Field 1 Text+  , dAge  :: Field 2 Int32+  , dFlag :: Field 3 Bool+  } deriving (Show, Eq, Generic)++instance ProtoMessage AllDefaults++data Address = Address+  { city :: Field 1 Text+  , addrZip :: Field 2 Int32+  } deriving (Show, Eq, Generic)++instance ProtoMessage Address++data Person = Person+  { pName :: Field 1 Text+  , pAddr :: Field 2 (Maybe Address)+  } deriving (Show, Eq, Generic)++instance ProtoMessage Person++data WithRepeatedInt = WithRepeatedInt+  { riName :: Field 1 Text+  , riNums :: Field 2 [Int32]+  } deriving (Show, Eq, Generic)++instance ProtoMessage WithRepeatedInt+++-- ===================================================================+-- 1. Wire primitives: Varint+-- ===================================================================++testVarintRoundtrip :: IO ()+testVarintRoundtrip = do+  putStrLn "=== Varint roundtrip ==="+  let check val = do+        let bs = buildBS (ProtoBuilder (varintSz val) (encodeVarint val))+        case decodeVarint bs of+          Just (v, rest) -> do+            assert ("varint roundtrip " ++ show val) (v == val)+            assert ("varint no leftover " ++ show val) (BS.null rest)+          Nothing -> error $ "FAIL: varint decode failed for " ++ show val+  check 0+  check 1+  check 127+  check 128+  check 300+  check (fromIntegral (maxBound @Word32) :: Word64)+  check (maxBound @Word64)+  where+    varintSz :: Word64 -> Int+    varintSz 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++-- ===================================================================+-- 2. Wire primitives: Zigzag+-- ===================================================================++testZigzagRoundtrip :: IO ()+testZigzagRoundtrip = do+  putStrLn "=== Zigzag roundtrip ==="+  let check val = assert ("zigzag roundtrip " ++ show val)+                         (zigzagDecode (zigzagEncode val) == val)+  check 0+  check (-1)+  check 1+  check (-2)+  check 2+  check (minBound @Int64)+  check (maxBound @Int64)+++-- ===================================================================+-- 3. Wire primitives: Fixed32/Fixed64+-- ===================================================================++testFixed32Roundtrip :: IO ()+testFixed32Roundtrip = do+  putStrLn "=== Fixed32 roundtrip ==="+  let check val = do+        let bs = buildBS (ProtoBuilder 4 (encodeFixed32 val))+        case decodeFixed32 bs of+          Just (v, rest) -> do+            assert ("fixed32 roundtrip " ++ show val) (v == val)+            assert ("fixed32 no leftover " ++ show val) (BS.null rest)+          Nothing -> error $ "FAIL: fixed32 decode failed for " ++ show val+  check 0+  check 1+  check 255+  check 65535+  check (maxBound @Word32)++testFixed64Roundtrip :: IO ()+testFixed64Roundtrip = do+  putStrLn "=== Fixed64 roundtrip ==="+  let check val = do+        let bs = buildBS (ProtoBuilder 8 (encodeFixed64 val))+        case decodeFixed64 bs of+          Just (v, rest) -> do+            assert ("fixed64 roundtrip " ++ show val) (v == val)+            assert ("fixed64 no leftover " ++ show val) (BS.null rest)+          Nothing -> error $ "FAIL: fixed64 decode failed for " ++ show val+  check 0+  check 1+  check (maxBound @Word64)+  check 0xDEADBEEFCAFEBABE+++-- ===================================================================+-- 4. Wire primitives: Float/Double+-- ===================================================================++testFloatRoundtrip :: IO ()+testFloatRoundtrip = do+  putStrLn "=== Float roundtrip ==="+  let check val = do+        let bs = buildBS (ProtoBuilder 4 (encodeProtoFloat val))+        case decodeProtoFloat bs of+          Just (v, rest) -> do+            assert ("float roundtrip " ++ show val) (v == val)+            assert ("float no leftover " ++ show val) (BS.null rest)+          Nothing -> error $ "FAIL: float decode failed for " ++ show val+  check 0.0+  check 1.5+  check (-3.14)+  check (1.0 / 0.0)  -- infinity++  -- NaN: NaN /= NaN, so test with isNaN+  let nanBs = buildBS (ProtoBuilder 4 (encodeProtoFloat (0.0 / 0.0)))+  case decodeProtoFloat nanBs of+    Just (v, rest) -> do+      assert "float NaN roundtrip" (isNaN v)+      assert "float NaN no leftover" (BS.null rest)+    Nothing -> error "FAIL: float NaN decode failed"++testDoubleRoundtrip :: IO ()+testDoubleRoundtrip = do+  putStrLn "=== Double roundtrip ==="+  let check val = do+        let bs = buildBS (ProtoBuilder 8 (encodeProtoDouble val))+        case decodeProtoDouble bs of+          Just (v, rest) -> do+            assert ("double roundtrip " ++ show val) (v == val)+            assert ("double no leftover " ++ show val) (BS.null rest)+          Nothing -> error $ "FAIL: double decode failed for " ++ show val+  check 0.0+  check 1.5+  check (-3.14)+  check (1.0 / 0.0)++  let nanBs = buildBS (ProtoBuilder 8 (encodeProtoDouble (0.0 / 0.0)))+  case decodeProtoDouble nanBs of+    Just (v, rest) -> do+      assert "double NaN roundtrip" (isNaN v)+      assert "double NaN no leftover" (BS.null rest)+    Nothing -> error "FAIL: double NaN decode failed"+++-- ===================================================================+-- 5. Wire primitives: Tags+-- ===================================================================++testTagEncodeDecode :: IO ()+testTagEncodeDecode = do+  putStrLn "=== Tag encode/decode ==="++  -- field 1, varint+  let bs1 = buildBS (ProtoBuilder 1 (encodeTag 1 WireVarint))+  case decodeTag bs1 of+    Just (fn, wt, rest) -> do+      assert "tag field 1 varint: field number" (fn == 1)+      assert "tag field 1 varint: wire type" (wt == WireVarint)+      assert "tag field 1 varint: no leftover" (BS.null rest)+    Nothing -> error "FAIL: tag decode failed"++  -- field 15, length-delimited+  let bs2 = buildBS (ProtoBuilder 1 (encodeTag 15 WireLengthDelimited))+  case decodeTag bs2 of+    Just (fn, wt, rest) -> do+      assert "tag field 15 ld: field number" (fn == 15)+      assert "tag field 15 ld: wire type" (wt == WireLengthDelimited)+      assert "tag field 15 ld: no leftover" (BS.null rest)+    Nothing -> error "FAIL: tag decode failed"++  -- field 536870911 (max field number: 2^29 - 1)+  let bs3 = buildBS (ProtoBuilder 5 (encodeTag 536870911 WireVarint))+  case decodeTag bs3 of+    Just (fn, wt, _) -> do+      assert "tag max field: field number" (fn == 536870911)+      assert "tag max field: wire type" (wt == WireVarint)+    Nothing -> error "FAIL: tag decode failed for max field"+++-- ===================================================================+-- 6. Wire primitives: Length-delimited+-- ===================================================================++testLengthDelimitedRoundtrip :: IO ()+testLengthDelimitedRoundtrip = do+  putStrLn "=== Length-delimited roundtrip ==="++  -- empty+  let emptyBs = buildBS (ProtoBuilder 1 (encodeLengthDelimited BS.empty))+  case decodeLengthDelimited emptyBs of+    Just (val, rest) -> do+      assert "length-delimited empty: value" (val == BS.empty)+      assert "length-delimited empty: no leftover" (BS.null rest)+    Nothing -> error "FAIL: length-delimited empty decode failed"++  -- "hello"+  let hello = "hello" :: ByteString+      helloBs = buildBS (ProtoBuilder (1 + BS.length hello) (encodeLengthDelimited hello))+  case decodeLengthDelimited helloBs of+    Just (val, rest) -> do+      assert "length-delimited hello: value" (val == hello)+      assert "length-delimited hello: no leftover" (BS.null rest)+    Nothing -> error "FAIL: length-delimited hello decode failed"++  -- 1000 bytes+  let bigBs = BS.replicate 1000 0x42+      bigEncoded = buildBS (ProtoBuilder (2 + 1000) (encodeLengthDelimited bigBs))+  case decodeLengthDelimited bigEncoded of+    Just (val, rest) -> do+      assert "length-delimited 1000 bytes: value" (val == bigBs)+      assert "length-delimited 1000 bytes: no leftover" (BS.null rest)+    Nothing -> error "FAIL: length-delimited 1000 bytes decode failed"+++-- ===================================================================+-- 7. Wire primitives: Skip field+-- ===================================================================++testSkipField :: IO ()+testSkipField = do+  putStrLn "=== Skip field ==="++  -- skip varint+  let varintBs = buildBS (ProtoBuilder 1 (encodeVarint 42)) <> "tail"+  case skipField WireVarint varintBs of+    Just rest -> assert "skip varint" (rest == "tail")+    Nothing -> error "FAIL: skip varint failed"++  -- skip fixed32+  let f32Bs = buildBS (ProtoBuilder 4 (encodeFixed32 0)) <> "tail"+  case skipField WireFixed32 f32Bs of+    Just rest -> assert "skip fixed32" (rest == "tail")+    Nothing -> error "FAIL: skip fixed32 failed"++  -- skip fixed64+  let f64Bs = buildBS (ProtoBuilder 8 (encodeFixed64 0)) <> "tail"+  case skipField WireFixed64 f64Bs of+    Just rest -> assert "skip fixed64" (rest == "tail")+    Nothing -> error "FAIL: skip fixed64 failed"++  -- skip length-delimited+  let ldBs = buildBS (ProtoBuilder 6 (encodeLengthDelimited "hello")) <> "tail"+  case skipField WireLengthDelimited ldBs of+    Just rest -> assert "skip length-delimited" (rest == "tail")+    Nothing -> error "FAIL: skip length-delimited failed"+++-- ===================================================================+-- 8. Encoding: ProtoEncode Int32+-- ===================================================================++testEncodeInt32 :: IO ()+testEncodeInt32 = do+  putStrLn "=== ProtoEncode Int32 ==="++  -- Non-default encodes as varint+  let pb = protoEncodeValue @Int32 42+  assert "Int32 42: encodes non-empty" (pbLength pb > 0)++  -- Field encoding: tag + value+  let fpb = encodeField (Field 42 :: Field 1 Int32)+  assert "Int32 field 1 42: encodes non-empty" (pbLength fpb > 0)++  -- Default (0) is skipped+  let defPb = encodeField (Field 0 :: Field 1 Int32)+  assert "Int32 default (0) is skipped" (pbLength defPb == 0)+++-- ===================================================================+-- 9. Encoding: ProtoEncode Bool+-- ===================================================================++testEncodeBool :: IO ()+testEncodeBool = do+  putStrLn "=== ProtoEncode Bool ==="++  -- True -> varint 1+  let truePb = protoEncodeValue True+  let trueBytes = runProtoBuilder truePb+  case decodeVarint trueBytes of+    Just (v, _) -> assert "Bool True encodes to varint 1" (v == 1)+    Nothing -> error "FAIL: Bool True encode failed"++  -- False -> field is skipped (default)+  let defPb = encodeField (Field False :: Field 1 Bool)+  assert "Bool False (default) is skipped" (pbLength defPb == 0)+++-- ===================================================================+-- 10. Encoding: ProtoEncode Text+-- ===================================================================++testEncodeText :: IO ()+testEncodeText = do+  putStrLn "=== ProtoEncode Text ==="++  let pb = protoEncodeValue ("hello" :: Text)+  -- length-delimited: varint(5) + "hello" = 6 bytes+  assert "Text 'hello': length 6" (pbLength pb == 6)++  -- Field encoding+  let fpb = encodeField (Field "hello" :: Field 1 Text)+  assert "Text field non-empty" (pbLength fpb > 0)++  -- Default ("") is skipped+  let defPb = encodeField (Field "" :: Field 1 Text)+  assert "Text default ('') is skipped" (pbLength defPb == 0)+++-- ===================================================================+-- 11. Encoding: ProtoEncode ByteString+-- ===================================================================++testEncodeByteString :: IO ()+testEncodeByteString = do+  putStrLn "=== ProtoEncode ByteString ==="++  let bs = "world" :: ByteString+      pb = protoEncodeValue bs+  -- varint(5) + "world" = 6 bytes+  assert "ByteString 'world': length 6" (pbLength pb == 6)++  -- Default (empty) is skipped+  let defPb = encodeField (Field BS.empty :: Field 1 ByteString)+  assert "ByteString default (empty) is skipped" (pbLength defPb == 0)+++-- ===================================================================+-- 12. Encoding: Optional and Repeated fields+-- ===================================================================++testEncodeOptionalField :: IO ()+testEncodeOptionalField = do+  putStrLn "=== Optional field encoding ==="++  -- Nothing -> empty+  let nothingPb = encodeOptionalField (Field Nothing :: Field 1 (Maybe Text))+  assert "Nothing encodes to empty" (pbLength nothingPb == 0)++  -- Just val -> field+  let justPb = encodeOptionalField (Field (Just "hi") :: Field 1 (Maybe Text))+  assert "Just 'hi' encodes non-empty" (pbLength justPb > 0)++testEncodeRepeatedField :: IO ()+testEncodeRepeatedField = do+  putStrLn "=== Repeated field encoding ==="++  -- Empty list -> empty+  let emptyPb = encodeRepeatedField (Field [] :: Field 1 [Text])+  assert "empty list encodes to empty" (pbLength emptyPb == 0)++  -- Non-empty list+  let listPb = encodeRepeatedField (Field ["a", "b", "c"] :: Field 1 [Text])+  assert "list [a,b,c] encodes non-empty" (pbLength listPb > 0)+++-- ===================================================================+-- 13. Encoding: ProtoBuilder length tracking+-- ===================================================================++testProtoBuilderLength :: IO ()+testProtoBuilderLength = do+  putStrLn "=== ProtoBuilder length tracking ==="++  -- Build several values and verify pbLength matches actual encoded bytes+  let pb1 = protoEncodeValue @Int32 42+      bs1 = runProtoBuilder pb1+  assert "Int32 42: pbLength matches actual" (pbLength pb1 == BS.length bs1)++  let pb2 = protoEncodeValue ("hello world" :: Text)+      bs2 = runProtoBuilder pb2+  assert "Text: pbLength matches actual" (pbLength pb2 == BS.length bs2)++  let pb3 = protoEncodeValue @Double 3.14+      bs3 = runProtoBuilder pb3+  assert "Double: pbLength matches actual" (pbLength pb3 == BS.length bs3)++  -- Combined+  let combined = pb1 <> pb2 <> pb3+      bsAll = runProtoBuilder combined+  assert "combined: pbLength matches actual" (pbLength combined == BS.length bsAll)+++-- ===================================================================+-- 14. Decoding: ProtoDecode Int32+-- ===================================================================++testDecodeInt32 :: IO ()+testDecodeInt32 = do+  putStrLn "=== ProtoDecode Int32 ==="++  assert "Int32 from varint 42" (protoDecodeValue @Int32 (RawVarint 42) == Right 42)+  assert "Int32 from varint 0"  (protoDecodeValue @Int32 (RawVarint 0) == Right 0)++  -- Wrong wire type+  case protoDecodeValue @Int32 (RawBytes "hello") of+    Left _ -> assert "Int32 rejects RawBytes" True+    Right _ -> assert "Int32 rejects RawBytes" False+++-- ===================================================================+-- 15. Decoding: ProtoDecode Text+-- ===================================================================++testDecodeText :: IO ()+testDecodeText = do+  putStrLn "=== ProtoDecode Text ==="++  assert "Text from RawBytes 'hello'" (protoDecodeValue @Text (RawBytes "hello") == Right "hello")+  assert "Text from RawBytes empty"   (protoDecodeValue @Text (RawBytes "") == Right "")++  -- Invalid UTF-8+  let badUtf8 = BS.pack [0xFF, 0xFE]+  case protoDecodeValue @Text (RawBytes badUtf8) of+    Left InvalidUtf8 -> assert "Text rejects invalid UTF-8" True+    _                -> assert "Text rejects invalid UTF-8" False+++-- ===================================================================+-- 16. Decoding: ProtoDecode ByteString+-- ===================================================================++testDecodeByteString :: IO ()+testDecodeByteString = do+  putStrLn "=== ProtoDecode ByteString ==="++  assert "ByteString from RawBytes" (protoDecodeValue @ByteString (RawBytes "hello") == Right "hello")+  assert "ByteString from RawBytes empty" (protoDecodeValue @ByteString (RawBytes "") == Right "")+++-- ===================================================================+-- 17. Decoding: parseFields+-- ===================================================================++testParseFields :: IO ()+testParseFields = do+  putStrLn "=== parseFields ==="++  -- Encode a Simple message and parse the raw fields+  let msg = Simple (Field "Alice") (Field 30)+      bs = encode msg+  case parseFields bs of+    Right fields -> do+      assert "parseFields: 2 fields" (length fields == 2)+      -- field 1 should be RawBytes (text)+      case lookup 1 fields of+        Just (RawBytes v) -> assert "parseFields: field 1 is 'Alice'" (v == "Alice")+        _ -> assert "parseFields: field 1 is RawBytes" False+      -- field 2 should be RawVarint+      case lookup 2 fields of+        Just (RawVarint v) -> assert "parseFields: field 2 is 30" (v == 30)+        _ -> assert "parseFields: field 2 is RawVarint" False+    Left err -> error $ "FAIL: parseFields failed: " ++ show err++  -- Empty input+  case parseFields BS.empty of+    Right fields -> assert "parseFields: empty input -> empty list" (null fields)+    Left err -> error $ "FAIL: parseFields empty failed: " ++ show err+++-- ===================================================================+-- 18. Decoding: Unknown fields skipped+-- ===================================================================++testUnknownFields :: IO ()+testUnknownFields = do+  putStrLn "=== Unknown fields skipped ==="++  -- Encode a Simple message, then try to decode it as AllDefaults+  -- (which expects fields 1, 2, 3). Since Simple only has fields 1, 2,+  -- field 3 will use its default.+  let msg = Simple (Field "Bob") (Field 25)+      bs = encode msg+  case decode @AllDefaults bs of+    Right ad -> do+      assert "unknown fields: name preserved" (unField (dName ad) == "Bob")+      assert "unknown fields: age preserved" (unField (dAge ad) == 25)+      assert "unknown fields: flag defaults to False" (unField (dFlag ad) == False)+    Left err -> error $ "FAIL: unknown fields decode failed: " ++ show err+++-- ===================================================================+-- 19. ProtoMessage: Simple roundtrip+-- ===================================================================++testSimpleRoundtrip :: IO ()+testSimpleRoundtrip = do+  putStrLn "=== Simple message roundtrip ==="++  let msg = Simple (Field "Alice") (Field 30)+      bs = encode msg+  case decode bs of+    Right msg' -> assert "Simple roundtrip" (msg == msg')+    Left err -> error $ "FAIL: Simple roundtrip: " ++ show err+++-- ===================================================================+-- 20. ProtoMessage: Optional fields+-- ===================================================================++testOptionalRoundtrip :: IO ()+testOptionalRoundtrip = do+  putStrLn "=== Optional fields roundtrip ==="++  -- With value+  let msg1 = WithOpt (Field "Alice") (Field (Just "Ali"))+  case decode (encode msg1) of+    Right msg1' -> assert "WithOpt Just roundtrip" (msg1 == msg1')+    Left err -> error $ "FAIL: WithOpt Just roundtrip: " ++ show err++  -- Without value+  let msg2 = WithOpt (Field "Bob") (Field Nothing)+  case decode (encode msg2) of+    Right msg2' -> assert "WithOpt Nothing roundtrip" (msg2 == msg2')+    Left err -> error $ "FAIL: WithOpt Nothing roundtrip: " ++ show err+++-- ===================================================================+-- 21. ProtoMessage: Repeated fields+-- ===================================================================++testRepeatedRoundtrip :: IO ()+testRepeatedRoundtrip = do+  putStrLn "=== Repeated fields roundtrip ==="++  let msg = WithList (Field ["alpha", "beta", "gamma"])+  case decode (encode msg) of+    Right msg' -> assert "WithList roundtrip" (msg == msg')+    Left err -> error $ "FAIL: WithList roundtrip: " ++ show err++  -- Empty list+  let msgEmpty = WithList (Field [])+  case decode (encode msgEmpty) of+    Right msg' -> assert "WithList empty roundtrip" (msg' == msgEmpty)+    Left err -> error $ "FAIL: WithList empty roundtrip: " ++ show err+++-- ===================================================================+-- 22. ProtoMessage: All-default values+-- ===================================================================++testDefaultsEncoding :: IO ()+testDefaultsEncoding = do+  putStrLn "=== Default values encoding ==="++  let msg = AllDefaults (Field "") (Field 0) (Field False)+      bs = encode msg+  assert "all-default message encodes to empty bytes" (BS.null bs)+++-- ===================================================================+-- 23. ProtoMessage: Missing fields use defaults+-- ===================================================================++testMissingFieldsDefault :: IO ()+testMissingFieldsDefault = do+  putStrLn "=== Missing fields use defaults ==="++  -- Decode empty bytes as AllDefaults+  case decode @AllDefaults BS.empty of+    Right ad -> do+      assert "missing field: name defaults to ''" (unField (dName ad) == "")+      assert "missing field: age defaults to 0" (unField (dAge ad) == 0)+      assert "missing field: flag defaults to False" (unField (dFlag ad) == False)+    Left err -> error $ "FAIL: missing fields decode: " ++ show err+++-- ===================================================================+-- 24. Nested message roundtrip+-- ===================================================================++testNestedMessageRoundtrip :: IO ()+testNestedMessageRoundtrip = do+  putStrLn "=== Nested message roundtrip ==="++  let addr = Address (Field "Springfield") (Field 62704)+      person = Person (Field "Homer") (Field (Just addr))+      bs = encode person+  case decode bs of+    Right person' -> assert "nested message roundtrip" (person == person')+    Left err -> error $ "FAIL: nested message roundtrip: " ++ show err++  -- Nested with Nothing+  let person2 = Person (Field "Bart") (Field Nothing)+  case decode (encode person2) of+    Right person2' -> assert "nested message Nothing roundtrip" (person2 == person2')+    Left err -> error $ "FAIL: nested Nothing roundtrip: " ++ show err+++-- ===================================================================+-- 25. Out-of-order field decoding+-- ===================================================================++testOutOfOrderDecoding :: IO ()+testOutOfOrderDecoding = do+  putStrLn "=== Out-of-order field decoding ==="++  -- Manually build bytes with field 2 (Int32, varint) before field 1 (Text, length-delimited)+  -- Field 2: tag = (2 << 3 | 0) = 16 = 0x10, value = varint 25+  -- Field 1: tag = (1 << 3 | 2) = 10 = 0x0A, value = length-delimited "Alice"+  let field2Bytes = buildBS (ProtoBuilder 1 (encodeTag 2 WireVarint))+                 <> buildBS (ProtoBuilder 1 (encodeVarint 25))+      field1Bytes = buildBS (ProtoBuilder 1 (encodeTag 1 WireLengthDelimited))+                 <> buildBS (ProtoBuilder 6 (encodeLengthDelimited "Alice"))+      outOfOrder = field2Bytes <> field1Bytes+  case decode @Simple outOfOrder of+    Right msg -> do+      assert "out-of-order: name is Alice" (unField (sName msg) == "Alice")+      assert "out-of-order: age is 25" (unField (sAge msg) == 25)+    Left err -> error $ "FAIL: out-of-order decode: " ++ show err+++-- ===================================================================+-- 26. Duplicate field (last-wins)+-- ===================================================================++testDuplicateFieldLastWins :: IO ()+testDuplicateFieldLastWins = do+  putStrLn "=== Duplicate field (last-wins) ==="++  -- Encode field 1 as "first", then field 2 as 10, then field 1 again as "second"+  let f1first = buildBS (ProtoBuilder 1 (encodeTag 1 WireLengthDelimited))+             <> buildBS (ProtoBuilder 6 (encodeLengthDelimited "first"))+      f2val   = buildBS (ProtoBuilder 1 (encodeTag 2 WireVarint))+             <> buildBS (ProtoBuilder 1 (encodeVarint 10))+      f1second = buildBS (ProtoBuilder 1 (encodeTag 1 WireLengthDelimited))+              <> buildBS (ProtoBuilder 7 (encodeLengthDelimited "second"))+      combined = f1first <> f2val <> f1second+  case decode @Simple combined of+    Right msg -> do+      assert "duplicate field last-wins: name is 'second'" (unField (sName msg) == "second")+      assert "duplicate field last-wins: age is 10" (unField (sAge msg) == 10)+    Left err -> error $ "FAIL: duplicate field decode: " ++ show err+++-- ===================================================================+-- 27. Malformed input tests+-- ===================================================================++testMalformedInput :: IO ()+testMalformedInput = do+  putStrLn "=== Malformed input tests ==="++  -- Truncated varint: 0x80 has continuation bit set but no following byte+  let truncVarint = BS.pack [0x80]+  case decode @Simple truncVarint of+    Left _ -> assert "truncated varint returns Left" True+    Right _ -> assert "truncated varint returns Left" False++  -- Truncated length-delimited: tag for field 1 string + varint length 100 + only 5 bytes+  let tagBytes = buildBS (ProtoBuilder 1 (encodeTag 1 WireLengthDelimited))+      lenBytes = buildBS (ProtoBuilder 1 (encodeVarint 100))+      dataBytes = BS.replicate 5 0x41+      truncLD = tagBytes <> lenBytes <> dataBytes+  case decode @Simple truncLD of+    Left _ -> assert "truncated length-delimited returns Left" True+    Right _ -> assert "truncated length-delimited returns Left" False++  -- Empty input: decode as Simple -> returns message with all defaults+  case decode @Simple BS.empty of+    Right msg -> do+      assert "empty input: name defaults to ''" (unField (sName msg) == "")+      assert "empty input: age defaults to 0" (unField (sAge msg) == 0)+    Left err -> error $ "FAIL: empty input decode: " ++ show err++  -- Single byte 0xFF -> decode returns Left (invalid tag: wire type 7 is unknown)+  let badByte = BS.pack [0xFF]+  case decode @Simple badByte of+    Left _ -> assert "single byte 0xFF returns Left" True+    Right _ -> assert "single byte 0xFF returns Left" False+++-- ===================================================================+-- 28. Overlong varint rejection+-- ===================================================================++testOverlongVarintRejection :: IO ()+testOverlongVarintRejection = do+  putStrLn "=== Overlong varint rejection ==="++  -- Construct a 10-byte varint where byte 10 has bits 1-6 set.+  -- 9 continuation bytes (0x80) + final byte with high bits: 0x7E (bits 1-6 set)+  -- This represents a varint that overflows the 64-bit space.+  let overlongVarint = BS.pack [0x80, 0x80, 0x80, 0x80, 0x80,+                                0x80, 0x80, 0x80, 0x80, 0x7E]+  case decodeVarint overlongVarint of+    Nothing -> assert "overlong varint rejected" True+    Just _  -> assert "overlong varint rejected" False++  -- A valid 10-byte varint (max Word64) should still decode+  -- maxBound :: Word64 = 0xFFFFFFFFFFFFFFFF+  -- As varint: 9 bytes of 0xFF + final byte 0x01+  let maxVarint = BS.pack [0xFF, 0xFF, 0xFF, 0xFF, 0xFF,+                           0xFF, 0xFF, 0xFF, 0xFF, 0x01]+  case decodeVarint maxVarint of+    Just (v, rest) -> do+      assert "max varint decodes correctly" (v == maxBound @Word64)+      assert "max varint no leftover" (BS.null rest)+    Nothing -> assert "max varint decodes correctly" False+++-- ===================================================================+-- 29. Zigzag encoding of negative values+-- ===================================================================++testZigzagNegativeEfficiency :: IO ()+testZigzagNegativeEfficiency = do+  putStrLn "=== Zigzag negative value efficiency ==="++  -- Small negatives should produce small zigzag-encoded values+  assert "zigzag(-1) = 1" (zigzagEncode (-1) == 1)+  assert "zigzag(-2) = 3" (zigzagEncode (-2) == 3)+  assert "zigzag(-100) = 199" (zigzagEncode (-100) == 199)++  -- Positive values should also be small+  assert "zigzag(0) = 0" (zigzagEncode 0 == 0)+  assert "zigzag(1) = 2" (zigzagEncode 1 == 2)+  assert "zigzag(100) = 200" (zigzagEncode 100 == 200)++  -- Verify zigzag of small negatives uses fewer bytes than plain int encoding+  -- -1 as Int32 on wire (varint) is 10 bytes (sign-extended to Word64)+  -- -1 as zigzag -> 1 -> 1 byte+  let plainNeg1Bytes = BS.length $ runProtoBuilder (protoEncodeValue @Int32 (-1))+      zigzagNeg1 = zigzagEncode (-1)+      zigzagVarintSz n+        | n < 0x80       = 1+        | n < 0x4000     = 2+        | otherwise      = 3+  assert "zigzag(-1) more compact than plain Int32(-1)"+    (zigzagVarintSz zigzagNeg1 < plainNeg1Bytes)+++-- ===================================================================+-- 30. Large message test+-- ===================================================================++testLargeMessage :: IO ()+testLargeMessage = do+  putStrLn "=== Large message test ==="++  -- Create a message with a 10KB Text field and a 100-element repeated Int32 list+  let bigText = T.replicate 10000 "a"  -- 10KB text+      nums = map fromIntegral [1..100 :: Int] :: [Int32]+      msg = WithRepeatedInt (Field bigText) (Field nums)+      bs = encode msg+  case decode @WithRepeatedInt bs of+    Right msg' -> do+      assert "large message roundtrip" (msg == msg')+      assert "large text preserved" (T.length (unField (riName msg')) == 10000)+      assert "repeated list length preserved" (length (unField (riNums msg')) == 100)+    Left err -> error $ "FAIL: large message roundtrip: " ++ show err+++-- ===================================================================+-- 31. Wire compatibility with proto3 spec+-- ===================================================================++-- | Equivalent of:+-- message TestCompat {+--   string name = 1;+--   int32 id = 2;+--   bool active = 3;+-- }+data TestCompat = TestCompat+  { tcName   :: Field 1 Text+  , tcId     :: Field 2 Int32+  , tcActive :: Field 3 Bool+  } deriving (Show, Eq, Generic)++instance ProtoMessage TestCompat++testWireCompatibility :: IO ()+testWireCompatibility = do+  putStrLn "=== Wire compatibility with proto3 spec ==="++  let msg = TestCompat (Field "alice") (Field 42) (Field True)+      encoded = encode msg++  -- Expected wire bytes computed from proto3 spec:+  -- Field 1 (string): tag=0x0A (field 1, wire type 2), length=5, "alice"+  -- Field 2 (int32):  tag=0x10 (field 2, wire type 0), varint 42 = 0x2A+  -- Field 3 (bool):   tag=0x18 (field 3, wire type 0), varint 1 = 0x01+  let expected = BS.pack [0x0A, 0x05, 0x61, 0x6C, 0x69, 0x63, 0x65, 0x10, 0x2A, 0x18, 0x01]++  assert "wire compat: encode matches proto3 spec bytes" (encoded == expected)++  -- Reverse: decode known bytes and verify values+  case decode @TestCompat expected of+    Right msg' -> do+      assert "wire compat: decode name"   (unField (tcName msg') == "alice")+      assert "wire compat: decode id"     (unField (tcId msg') == 42)+      assert "wire compat: decode active" (unField (tcActive msg') == True)+    Left err -> error $ "FAIL: wire compat decode: " ++ show err+++-- ===================================================================+-- 31b. Negative Int32 wire compatibility+-- ===================================================================++testNegativeInt32Wire :: IO ()+testNegativeInt32Wire = do+  putStrLn "=== Negative Int32 wire compatibility ==="++  -- Proto3 encodes negative int32 as 10-byte varint (sign-extended to 64 bits).+  -- Simple { sName = Field "test", sAge = Field (-1) }+  -- Field 1 (Text "test"): tag = 0x0A (field 1, wire type 2), length = 4, "test" = 74 65 73 74+  -- Field 2 (Int32 -1):    tag = 0x10 (field 2, wire type 0),+  --   value = varint(0xFFFFFFFFFFFFFFFF) = FF FF FF FF FF FF FF FF FF 01 (10 bytes)+  let msg = Simple (Field "test") (Field (-1))+      encoded = encode msg+      expected = BS.pack+        [ 0x0A, 0x04, 0x74, 0x65, 0x73, 0x74  -- field 1: "test"+        , 0x10                                  -- field 2: tag+        , 0xFF, 0xFF, 0xFF, 0xFF, 0xFF          -- 10-byte varint for -1+        , 0xFF, 0xFF, 0xFF, 0xFF, 0x01          -- (sign-extended to 64 bits)+        ]+  assert "negative int32: encode matches proto3 spec" (encoded == expected)++  -- Reverse: decode known bytes+  case decode @Simple expected of+    Right msg' -> do+      assert "negative int32: decode name" (unField (sName msg') == "test")+      assert "negative int32: decode age" (unField (sAge msg') == -1)+    Left err -> error $ "FAIL: negative int32 decode: " ++ show err+++-- ===================================================================+-- 31c. Bool field wire format+-- ===================================================================++testBoolWireFormat :: IO ()+testBoolWireFormat = do+  putStrLn "=== Bool field wire format ==="++  -- true encodes as varint 1; false is omitted (proto3 default)+  -- AllDefaults { dName = "", dAge = 0, dFlag = True }+  -- name="" -> skipped, age=0 -> skipped+  -- field 3 (Bool True): tag = 0x18 (field 3, wire type 0), value = varint 1 = 0x01+  let msgTrue = AllDefaults (Field "") (Field 0) (Field True)+      encodedTrue = encode msgTrue+      expectedTrue = BS.pack [0x18, 0x01]+  assert "bool true: encode matches proto3 spec" (encodedTrue == expectedTrue)++  case decode @AllDefaults expectedTrue of+    Right msg' -> do+      assert "bool true: decode flag" (unField (dFlag msg') == True)+      assert "bool true: decode name default" (unField (dName msg') == "")+      assert "bool true: decode age default" (unField (dAge msg') == 0)+    Left err -> error $ "FAIL: bool true decode: " ++ show err++  -- false is proto3 default -> all-default message encodes to empty+  let msgFalse = AllDefaults (Field "") (Field 0) (Field False)+      encodedFalse = encode msgFalse+  assert "bool false: omitted on wire (empty bytes)" (BS.null encodedFalse)+++-- ===================================================================+-- 31d. Empty string field wire format+-- ===================================================================++testEmptyStringWire :: IO ()+testEmptyStringWire = do+  putStrLn "=== Empty string field wire format ==="++  -- Proto3: "" is default for string, so present-but-empty is omitted.+  -- Simple { sName = "", sAge = 42 }+  -- name="" -> skipped, age=42 -> tag=0x10, varint 42=0x2A+  let msg = Simple (Field "") (Field 42)+      encoded = encode msg+      expected = BS.pack [0x10, 0x2A]+  assert "empty string: omitted on wire" (encoded == expected)++  case decode @Simple expected of+    Right msg' -> do+      assert "empty string: decode name defaults to ''" (unField (sName msg') == "")+      assert "empty string: decode age" (unField (sAge msg') == 42)+    Left err -> error $ "FAIL: empty string decode: " ++ show err+++-- ===================================================================+-- 31e. Varint edge cases (multi-byte boundaries)+-- ===================================================================++testVarintEdgeCases :: IO ()+testVarintEdgeCases = do+  putStrLn "=== Varint edge cases ==="++  -- Value 128: 2-byte varint 0x80 0x01+  -- Simple { sName = "", sAge = 128 }+  -- name="" -> skipped, age=128 -> tag=0x10, varint [0x80, 0x01]+  let msg128 = Simple (Field "") (Field 128)+      encoded128 = encode msg128+      expected128 = BS.pack [0x10, 0x80, 0x01]+  assert "varint 128: 2-byte encoding" (encoded128 == expected128)++  case decode @Simple expected128 of+    Right msg' -> assert "varint 128: roundtrip" (unField (sAge msg') == 128)+    Left err -> error $ "FAIL: varint 128 decode: " ++ show err++  -- Value 16384: 3-byte varint 0x80 0x80 0x01+  -- Simple { sName = "", sAge = 16384 }+  let msg16384 = Simple (Field "") (Field 16384)+      encoded16384 = encode msg16384+      expected16384 = BS.pack [0x10, 0x80, 0x80, 0x01]+  assert "varint 16384: 3-byte encoding" (encoded16384 == expected16384)++  case decode @Simple expected16384 of+    Right msg' -> assert "varint 16384: roundtrip" (unField (sAge msg') == 16384)+    Left err -> error $ "FAIL: varint 16384 decode: " ++ show err+++-- ===================================================================+-- 31f. Nested message wire bytes+-- ===================================================================++testNestedMessageWire :: IO ()+testNestedMessageWire = do+  putStrLn "=== Nested message wire bytes ==="++  -- Person { pName = "Homer", pAddr = Just (Address { city = "NYC", addrZip = 100 }) }+  --+  -- Address submessage:+  --   field 1 (Text "NYC"): tag=0x0A, len=3, 4E 59 43+  --   field 2 (Int32 100):  tag=0x10, varint 100=0x64+  --   submessage bytes: 0A 03 4E 59 43 10 64 (7 bytes)+  --+  -- Person:+  --   field 1 (Text "Homer"): tag=0x0A, len=5, 48 6F 6D 65 72+  --   field 2 (submessage):   tag=0x12 (field 2, wire type 2), len=7, <submessage>+  let addr = Address (Field "NYC") (Field 100)+      person = Person (Field "Homer") (Field (Just addr))+      encoded = encode person+      expected = BS.pack+        [ 0x0A, 0x05, 0x48, 0x6F, 0x6D, 0x65, 0x72  -- field 1: "Homer"+        , 0x12, 0x07                                   -- field 2: tag + length 7+        , 0x0A, 0x03, 0x4E, 0x59, 0x43                -- submessage field 1: "NYC"+        , 0x10, 0x64                                   -- submessage field 2: 100+        ]+  assert "nested message: encode matches hand-computed bytes" (encoded == expected)++  case decode @Person expected of+    Right person' -> do+      assert "nested message: decode name" (unField (pName person') == "Homer")+      case unField (pAddr person') of+        Just addr' -> do+          assert "nested message: decode city" (unField (city addr') == "NYC")+          assert "nested message: decode zip" (unField (addrZip addr') == 100)+        Nothing -> error "FAIL: nested message decode: addr is Nothing"+    Left err -> error $ "FAIL: nested message wire decode: " ++ show err+++-- ===================================================================+-- 31g. Repeated field (unpacked) wire bytes+-- ===================================================================++testRepeatedFieldWire :: IO ()+testRepeatedFieldWire = do+  putStrLn "=== Repeated field (unpacked) wire bytes ==="++  -- WithList { wTags = ["ab", "cd"] }+  -- Each string gets its own tag (field 1, wire type 2 = 0x0A):+  --   "ab": tag=0x0A, len=2, 61 62+  --   "cd": tag=0x0A, len=2, 63 64+  let msg = WithList (Field ["ab", "cd"])+      encoded = encode msg+      expected = BS.pack+        [ 0x0A, 0x02, 0x61, 0x62  -- "ab"+        , 0x0A, 0x02, 0x63, 0x64  -- "cd"+        ]+  assert "repeated unpacked: encode matches hand-computed bytes" (encoded == expected)++  case decode @WithList expected of+    Right msg' -> assert "repeated unpacked: decode roundtrip"+                         (unField (wTags msg') == ["ab", "cd"])+    Left err -> error $ "FAIL: repeated unpacked decode: " ++ show err++  -- Verify that multiple entries for same field are correctly merged on decode+  -- Manually construct: "ab" entry, then "cd" entry, then "ef" entry+  let extraEntry = BS.pack [0x0A, 0x02, 0x65, 0x66]  -- "ef"+      extended = expected <> extraEntry+  case decode @WithList extended of+    Right msg' -> assert "repeated unpacked: merge 3 entries"+                         (unField (wTags msg') == ["ab", "cd", "ef"])+    Left err -> error $ "FAIL: repeated unpacked merge: " ++ show err+++-- ===================================================================+-- 32. Packed repeated field decoding+-- ===================================================================++data Packed = Packed+  { pScores :: Field 1 [Int32]+  } deriving (Show, Eq, Generic)++instance ProtoMessage Packed++testPackedRepeatedRoundtrip :: IO ()+testPackedRepeatedRoundtrip = do+  putStrLn "=== Packed repeated field decoding ==="++  -- Encode using packed format (single length-delimited blob)+  let scores = [10, 20, 30, 40, 50] :: [Int32]+      packedBytes = runProtoBuilder (encodePackedField (Field scores :: Field 1 [Int32]))+  -- Decode using Generics decoder (which now handles packed)+  case decode @Packed packedBytes of+    Right msg -> assert "packed repeated roundtrip" (unField (pScores msg) == scores)+    Left err -> error $ "FAIL: packed repeated roundtrip: " ++ show err++  -- Encode using unpacked format (each element individually tagged)+  let unpackedBytes = runProtoBuilder (encodeRepeatedField (Field scores :: Field 1 [Int32]))+  case decode @Packed unpackedBytes of+    Right msg -> assert "unpacked repeated roundtrip" (unField (pScores msg) == scores)+    Left err -> error $ "FAIL: unpacked repeated roundtrip: " ++ show err++  -- Empty packed field+  let emptyPacked = runProtoBuilder (encodePackedField (Field [] :: Field 1 [Int32]))+  case decode @Packed emptyPacked of+    Right msg -> assert "empty packed roundtrip" (unField (pScores msg) == [])+    Left err -> error $ "FAIL: empty packed roundtrip: " ++ show err+++-- ===================================================================+-- 33. Mixed packed/unpacked entries+-- ===================================================================++testMixedPackedUnpacked :: IO ()+testMixedPackedUnpacked = do+  putStrLn "=== Mixed packed/unpacked entries ==="++  -- Manually construct wire bytes with a packed blob AND individual entries+  -- for the same field number 1.+  -- First: unpacked entry for value 1+  let unpacked1 = runProtoBuilder (encodeRepeatedField (Field [1 :: Int32] :: Field 1 [Int32]))+  -- Second: packed blob for values [2, 3, 4]+  let packed234 = runProtoBuilder (encodePackedField (Field [2, 3, 4 :: Int32] :: Field 1 [Int32]))+  -- Third: unpacked entry for value 5+  let unpacked5 = runProtoBuilder (encodeRepeatedField (Field [5 :: Int32] :: Field 1 [Int32]))+  -- Concatenate them (this is valid proto3: decoder must merge all entries)+  let mixed = unpacked1 <> packed234 <> unpacked5+  case decode @Packed mixed of+    Right msg -> assert "mixed packed/unpacked decodes all values"+                        (unField (pScores msg) == [1, 2, 3, 4, 5])+    Left err -> error $ "FAIL: mixed packed/unpacked: " ++ show err+++-- ===================================================================+-- 34. ProtoMap field roundtrip+-- ===================================================================++data WithMap = WithMap+  { wmLabels :: Field 1 (ProtoMap Text Int32)+  } deriving (Show, Eq, Generic)++instance ProtoMessage WithMap++testMapRoundtrip :: IO ()+testMapRoundtrip = do+  putStrLn "=== ProtoMap field roundtrip ==="++  -- Non-empty map+  let m = Map.fromList [("alpha", 1), ("beta", 2), ("gamma", 3)]+      msg = WithMap (Field (ProtoMap m))+  case decode (encode msg) of+    Right msg' -> assert "map roundtrip" (msg == msg')+    Left err -> error $ "FAIL: map roundtrip: " ++ show err+++-- ===================================================================+-- 35. Empty map+-- ===================================================================++testEmptyMap :: IO ()+testEmptyMap = do+  putStrLn "=== Empty map ==="++  let msg = WithMap (Field (ProtoMap Map.empty))+      bs = encode msg+  -- Empty map should encode to empty bytes (no entries)+  assert "empty map encodes to empty bytes" (BS.null bs)++  case decode @WithMap bs of+    Right msg' -> assert "empty map roundtrip" (msg == msg')+    Left err -> error $ "FAIL: empty map roundtrip: " ++ show err+++-- ===================================================================+-- 36. Map with multiple entry types+-- ===================================================================++data WithMaps = WithMaps+  { wmStrStr :: Field 1 (ProtoMap Text Text)+  , wmStrInt :: Field 2 (ProtoMap Text Int32)+  } deriving (Show, Eq, Generic)++instance ProtoMessage WithMaps++testMultipleMaps :: IO ()+testMultipleMaps = do+  putStrLn "=== Multiple map fields ==="++  let msg = WithMaps+              (Field (ProtoMap (Map.fromList [("key1", "val1"), ("key2", "val2")])))+              (Field (ProtoMap (Map.fromList [("count", 42)])))+  case decode (encode msg) of+    Right msg' -> assert "multiple maps roundtrip" (msg == msg')+    Left err -> error $ "FAIL: multiple maps roundtrip: " ++ show err+++-- ===================================================================+-- Protoc cross-validation: message types matching test.proto+-- ===================================================================++-- | Matches test.SimpleMsg in test.proto+data ProtoSimpleMsg = ProtoSimpleMsg+  { psName   :: Field 1 Text+  , psAge    :: Field 2 Int32+  , psActive :: Field 3 Bool+  } deriving (Show, Eq, Generic)++instance ProtoMessage ProtoSimpleMsg++-- | Matches test.NestedMsg in test.proto+data ProtoNestedMsg = ProtoNestedMsg+  { pnTitle  :: Field 1 Text+  , pnAuthor :: Field 2 (Maybe ProtoSimpleMsg)+  } deriving (Show, Eq, Generic)++instance ProtoMessage ProtoNestedMsg++-- | Matches test.RepeatedMsg in test.proto+data ProtoRepeatedMsg = ProtoRepeatedMsg+  { prValues :: Field 1 [Int32]+  , prTags   :: Field 2 [Text]+  } deriving (Show, Eq, Generic)++instance ProtoMessage ProtoRepeatedMsg++-- | Matches test.MapMsg in test.proto+data ProtoMapMsg = ProtoMapMsg+  { pmScores :: Field 1 (ProtoMap Text Int32)+  } deriving (Show, Eq, Generic)++instance ProtoMessage ProtoMapMsg+++-- ===================================================================+-- Protoc cross-validation: helpers+-- ===================================================================++-- | Check if protoc is available on the system.+hasProtoc :: IO Bool+hasProtoc = do+  result <- try @SomeException (readProcess "protoc" ["--version"] "")+  pure (isRight result)++-- | Pipe binary bytes to protoc --decode, returning the text-format output.+protocDecode :: String -> ByteString -> IO String+protocDecode msgName bytes = do+  let cp = (proc "protoc" ["--decode=" ++ msgName, "--proto_path=test", "test.proto"])+        { std_in = CreatePipe, std_out = CreatePipe, std_err = CreatePipe }+  (Just hin, Just hout, Just _herr, ph) <- createProcess cp+  hSetBinaryMode hin True+  BS.hPut hin bytes+  hFlush hin+  hClose hin+  output <- hGetContents hout+  _ <- waitForProcess ph+  pure output++-- | Pipe text-format to protoc --encode, returning the binary output.+protocEncode :: String -> String -> IO ByteString+protocEncode msgName textFormat = do+  let cp = (proc "protoc" ["--encode=" ++ msgName, "--proto_path=test", "test.proto"])+        { std_in = CreatePipe, std_out = CreatePipe, std_err = CreatePipe }+  (Just hin, Just hout, Just _herr, ph) <- createProcess cp+  hSetBinaryMode hout True+  hPutStr' hin textFormat+  hFlush hin+  hClose hin+  output <- BS.hGetContents hout+  _ <- waitForProcess ph+  pure output+  where+    hPutStr' h s = do+      let bs = BS.pack (map (toEnum . fromEnum) s)+      BS.hPut h bs+++-- ===================================================================+-- Protoc cross-validation: Test A — Our encode -> protoc decode+-- ===================================================================++testProtocDecodeSimple :: IO ()+testProtocDecodeSimple = do+  putStrLn "=== Protoc cross-validation: our encode -> protoc decode (SimpleMsg) ==="+  let msg = ProtoSimpleMsg (Field "alice") (Field 30) (Field True)+      bytes = Spire.Protobuf.encode msg+  output <- protocDecode "test.SimpleMsg" bytes+  assert "protoc decode: has name" ("name: \"alice\"" `isInfixOf` output)+  assert "protoc decode: has age" ("age: 30" `isInfixOf` output)+  assert "protoc decode: has active" ("active: true" `isInfixOf` output)++testProtocDecodeNested :: IO ()+testProtocDecodeNested = do+  putStrLn "=== Protoc cross-validation: our encode -> protoc decode (NestedMsg) ==="+  let author = ProtoSimpleMsg (Field "bob") (Field 25) (Field False)+      msg = ProtoNestedMsg (Field "My Article") (Field (Just author))+      bytes = Spire.Protobuf.encode msg+  output <- protocDecode "test.NestedMsg" bytes+  assert "protoc decode nested: has title" ("title: \"My Article\"" `isInfixOf` output)+  assert "protoc decode nested: has author name" ("name: \"bob\"" `isInfixOf` output)+  assert "protoc decode nested: has author age" ("age: 25" `isInfixOf` output)++testProtocDecodeRepeated :: IO ()+testProtocDecodeRepeated = do+  putStrLn "=== Protoc cross-validation: our encode -> protoc decode (RepeatedMsg) ==="+  let msg = ProtoRepeatedMsg (Field [10, 20, 30]) (Field ["hello", "world"])+      bytes = Spire.Protobuf.encode msg+  output <- protocDecode "test.RepeatedMsg" bytes+  assert "protoc decode repeated: has values" ("values: 10" `isInfixOf` output)+  assert "protoc decode repeated: has value 30" ("values: 30" `isInfixOf` output)+  assert "protoc decode repeated: has tag hello" ("tags: \"hello\"" `isInfixOf` output)+  assert "protoc decode repeated: has tag world" ("tags: \"world\"" `isInfixOf` output)++testProtocDecodeMap :: IO ()+testProtocDecodeMap = do+  putStrLn "=== Protoc cross-validation: our encode -> protoc decode (MapMsg) ==="+  let msg = ProtoMapMsg (Field (ProtoMap (Map.fromList [("alice", 100), ("bob", 200)])))+      bytes = Spire.Protobuf.encode msg+  output <- protocDecode "test.MapMsg" bytes+  assert "protoc decode map: has alice" ("key: \"alice\"" `isInfixOf` output)+  assert "protoc decode map: has alice score" ("value: 100" `isInfixOf` output)+  assert "protoc decode map: has bob" ("key: \"bob\"" `isInfixOf` output)+  assert "protoc decode map: has bob score" ("value: 200" `isInfixOf` output)+++-- ===================================================================+-- Protoc cross-validation: Test B — protoc encode -> our decode+-- ===================================================================++testProtocEncodeSimple :: IO ()+testProtocEncodeSimple = do+  putStrLn "=== Protoc cross-validation: protoc encode -> our decode (SimpleMsg) ==="+  let textFormat = unlines+        [ "name: \"bob\""+        , "age: 25"+        , "active: false"+        ]+  bytes <- protocEncode "test.SimpleMsg" textFormat+  case Spire.Protobuf.decode bytes of+    Right msg -> do+      assert "protoc encode: name matches" (unField (psName msg) == "bob")+      assert "protoc encode: age matches" (unField (psAge msg) == 25)+      assert "protoc encode: active matches" (unField (psActive msg) == False)+    Left err -> error $ "FAIL: protoc encode simple decode: " ++ show err++testProtocEncodeNested :: IO ()+testProtocEncodeNested = do+  putStrLn "=== Protoc cross-validation: protoc encode -> our decode (NestedMsg) ==="+  let textFormat = unlines+        [ "title: \"Research Paper\""+        , "author {"+        , "  name: \"carol\""+        , "  age: 35"+        , "  active: true"+        , "}"+        ]+  bytes <- protocEncode "test.NestedMsg" textFormat+  case Spire.Protobuf.decode @ProtoNestedMsg bytes of+    Right msg -> do+      assert "protoc encode nested: title matches" (unField (pnTitle msg) == "Research Paper")+      case unField (pnAuthor msg) of+        Just author -> do+          assert "protoc encode nested: author name" (unField (psName author) == "carol")+          assert "protoc encode nested: author age" (unField (psAge author) == 35)+          assert "protoc encode nested: author active" (unField (psActive author) == True)+        Nothing -> error "FAIL: protoc encode nested: author is Nothing"+    Left err -> error $ "FAIL: protoc encode nested decode: " ++ show err++testProtocEncodeRepeated :: IO ()+testProtocEncodeRepeated = do+  putStrLn "=== Protoc cross-validation: protoc encode -> our decode (RepeatedMsg) ==="+  let textFormat = unlines+        [ "values: 5"+        , "values: 10"+        , "values: 15"+        , "tags: \"foo\""+        , "tags: \"bar\""+        ]+  bytes <- protocEncode "test.RepeatedMsg" textFormat+  case Spire.Protobuf.decode @ProtoRepeatedMsg bytes of+    Right msg -> do+      assert "protoc encode repeated: values match" (unField (prValues msg) == [5, 10, 15])+      assert "protoc encode repeated: tags match" (unField (prTags msg) == ["foo", "bar"])+    Left err -> error $ "FAIL: protoc encode repeated decode: " ++ show err+++-- ===================================================================+-- Protoc cross-validation: Test C — roundtrip (our encode -> protoc -> our decode)+-- ===================================================================++testProtocRoundtrip :: IO ()+testProtocRoundtrip = do+  putStrLn "=== Protoc cross-validation: full roundtrip ==="+  -- Encode with our library, decode to text with protoc, re-encode with protoc,+  -- decode with our library — should get the same message back.+  let original = ProtoSimpleMsg (Field "roundtrip") (Field 99) (Field True)+      bytes1 = Spire.Protobuf.encode original+  -- Our bytes -> protoc text+  textOutput <- protocDecode "test.SimpleMsg" bytes1+  -- protoc text -> protoc bytes+  bytes2 <- protocEncode "test.SimpleMsg" textOutput+  -- protoc bytes -> our decode+  case Spire.Protobuf.decode @ProtoSimpleMsg bytes2 of+    Right decoded -> assert "full roundtrip: message preserved" (decoded == original)+    Left err -> error $ "FAIL: full roundtrip decode: " ++ show err+++-- ===================================================================+-- Protoc cross-validation: Test D — default values (empty message)+-- ===================================================================++testProtocDefaults :: IO ()+testProtocDefaults = do+  putStrLn "=== Protoc cross-validation: default/empty message ==="+  -- Encode a default (all-zeros) message with our library+  let msg = ProtoSimpleMsg (Field "") (Field 0) (Field False)+      bytes = Spire.Protobuf.encode msg+  -- Proto3: default values are not encoded on the wire, so the output+  -- should be empty (protoc --decode shows nothing for default values)+  assert "default msg: empty encoding" (BS.null bytes)+  -- Decode an empty message from protoc+  bytes2 <- protocEncode "test.SimpleMsg" ""+  case Spire.Protobuf.decode @ProtoSimpleMsg bytes2 of+    Right decoded -> do+      assert "protoc default: name is empty" (unField (psName decoded) == "")+      assert "protoc default: age is 0" (unField (psAge decoded) == 0)+      assert "protoc default: active is false" (unField (psActive decoded) == False)+    Left err -> error $ "FAIL: protoc default decode: " ++ show err+++-- ===================================================================+-- Protoc cross-validation: entry point+-- ===================================================================++testProtocCrossValidation :: IO ()+testProtocCrossValidation = do+  ok <- hasProtoc+  if ok+    then do+      testProtocDecodeSimple+      testProtocDecodeNested+      testProtocDecodeRepeated+      testProtocDecodeMap+      testProtocEncodeSimple+      testProtocEncodeNested+      testProtocEncodeRepeated+      testProtocRoundtrip+      testProtocDefaults+    else putStrLn "  SKIP: protoc not found (install with: brew install protobuf)"+++-- ===================================================================+-- Main+-- ===================================================================++main :: IO ()+main = do+  putStrLn "spire-protobuf unit tests"+  putStrLn (replicate 40 '-')++  testVarintRoundtrip+  testZigzagRoundtrip+  testFixed32Roundtrip+  testFixed64Roundtrip+  testFloatRoundtrip+  testDoubleRoundtrip+  testTagEncodeDecode+  testLengthDelimitedRoundtrip+  testSkipField+  testEncodeInt32+  testEncodeBool+  testEncodeText+  testEncodeByteString+  testEncodeOptionalField+  testEncodeRepeatedField+  testProtoBuilderLength+  testDecodeInt32+  testDecodeText+  testDecodeByteString+  testParseFields+  testUnknownFields+  testSimpleRoundtrip+  testOptionalRoundtrip+  testRepeatedRoundtrip+  testDefaultsEncoding+  testMissingFieldsDefault+  testNestedMessageRoundtrip+  testOutOfOrderDecoding+  testDuplicateFieldLastWins+  testMalformedInput+  testOverlongVarintRejection+  testZigzagNegativeEfficiency+  testLargeMessage+  testWireCompatibility+  testNegativeInt32Wire+  testBoolWireFormat+  testEmptyStringWire+  testVarintEdgeCases+  testNestedMessageWire+  testRepeatedFieldWire+  testPackedRepeatedRoundtrip+  testMixedPackedUnpacked+  testMapRoundtrip+  testEmptyMap+  testMultipleMaps+  testProtocCrossValidation++  putStrLn (replicate 40 '-')+  putStrLn "All tests passed!"
+ test/Properties.hs view
@@ -0,0 +1,253 @@+{-# LANGUAGE DataKinds #-}+{-# LANGUAGE DeriveGeneric #-}+{-# LANGUAGE TypeApplications #-}+{-# LANGUAGE TemplateHaskell #-}+{-# LANGUAGE FlexibleInstances #-}+{-# LANGUAGE UndecidableInstances #-}++module Main (main) where++import qualified Data.ByteString as BS+import Data.Int (Int32)+import qualified Data.Map.Strict as Map+import Data.Text (Text)+import Data.Word (Word64)+import GHC.Generics (Generic)++import Hedgehog+import qualified Hedgehog.Gen as Gen+import qualified Hedgehog.Range as Range++import Spire.Protobuf+import Spire.Protobuf.Wire (encodeFixed32, encodeFixed64, decodeVarint, decodeFixed32,+                            decodeFixed64, encodeLengthDelimited, decodeLengthDelimited,+                            zigzagEncode, zigzagDecode)+import Spire.Protobuf.Encode (runProtoBuilder, ProtoBuilder(..), encodePackedField)+++-- ===================================================================+-- Test message types+-- ===================================================================++data Simple = Simple+  { sName :: Field 1 Text+  , sAge  :: Field 2 Int32+  } deriving (Show, Eq, Generic)++instance ProtoMessage Simple++data WithOpt = WithOpt+  { wName :: Field 1 Text+  , wNick :: Field 2 (Maybe Text)+  } deriving (Show, Eq, Generic)++instance ProtoMessage WithOpt++data AllDefaults = AllDefaults+  { dName :: Field 1 Text+  , dAge  :: Field 2 Int32+  , dFlag :: Field 3 Bool+  } deriving (Show, Eq, Generic)++instance ProtoMessage AllDefaults++data Address = Address+  { city :: Field 1 Text+  , addrZip :: Field 2 Int32+  } deriving (Show, Eq, Generic)++instance ProtoMessage Address++data Person = Person+  { pName :: Field 1 Text+  , pAddr :: Field 2 (Maybe Address)+  } deriving (Show, Eq, Generic)++instance ProtoMessage Person+++-- ===================================================================+-- Generators+-- ===================================================================++genText :: Gen Text+genText = Gen.text (Range.linear 0 200) Gen.unicode++genSimple :: Gen Simple+genSimple = Simple+  <$> (Field <$> genText)+  <*> (Field <$> Gen.int32 Range.linearBounded)++genWithOpt :: Gen WithOpt+genWithOpt = WithOpt+  <$> (Field <$> genText)+  <*> (Field <$> Gen.maybe genText)++genAddress :: Gen Address+genAddress = Address+  <$> (Field <$> genText)+  <*> (Field <$> Gen.int32 Range.linearBounded)++genPerson :: Gen Person+genPerson = Person+  <$> (Field <$> genText)+  <*> (Field <$> Gen.maybe genAddress)+++-- ===================================================================+-- Properties+-- ===================================================================++-- | For any Word64, decoding the varint encoding gives back the original.+prop_varintRoundtrip :: Property+prop_varintRoundtrip = property $ do+  n <- forAll $ Gen.word64 Range.linearBounded+  let bs = runProtoBuilder (protoEncodeValue @Word64 n)+  case decodeVarint bs of+    Just (v, rest) -> do+      v === n+      assert (BS.null rest)+    Nothing -> failure++-- | For any Int64, zigzagDecode (zigzagEncode n) == n.+prop_zigzagRoundtrip :: Property+prop_zigzagRoundtrip = property $ do+  n <- forAll $ Gen.int64 Range.linearBounded+  zigzagDecode (zigzagEncode n) === n++-- | For any Word32, fixed32 roundtrip works.+prop_fixed32Roundtrip :: Property+prop_fixed32Roundtrip = property $ do+  n <- forAll $ Gen.word32 Range.linearBounded+  let bs = runProtoBuilder (ProtoBuilder 4 (encodeFixed32 n))+  case decodeFixed32 bs of+    Just (v, rest) -> do+      v === n+      assert (BS.null rest)+    Nothing -> failure++-- | For any Word64, fixed64 roundtrip works.+prop_fixed64Roundtrip :: Property+prop_fixed64Roundtrip = property $ do+  n <- forAll $ Gen.word64 Range.linearBounded+  let bs = runProtoBuilder (ProtoBuilder 8 (encodeFixed64 n))+  case decodeFixed64 bs of+    Just (v, rest) -> do+      v === n+      assert (BS.null rest)+    Nothing -> failure++-- | For any ByteString, length-delimited roundtrip works.+prop_lengthDelimitedRoundtrip :: Property+prop_lengthDelimitedRoundtrip = property $ do+  input <- forAll $ Gen.bytes (Range.linear 0 5000)+  let len = BS.length input+      varintLen n+        | n < 0x80       = 1+        | n < 0x4000     = 2+        | n < 0x200000   = 3+        | otherwise      = 4+      totalLen = varintLen len + len+      bs = runProtoBuilder (ProtoBuilder totalLen (encodeLengthDelimited input))+  case decodeLengthDelimited bs of+    Just (v, rest) -> do+      v === input+      assert (BS.null rest)+    Nothing -> failure++-- | For a Simple message with random name/age, decode (encode msg) == Right msg.+prop_messageRoundtrip :: Property+prop_messageRoundtrip = property $ do+  msg <- forAll genSimple+  decode (encode msg) === Right msg++-- | For WithOpt with random Maybe Text, roundtrip works.+prop_optionalFieldRoundtrip :: Property+prop_optionalFieldRoundtrip = property $ do+  msg <- forAll genWithOpt+  decode (encode msg) === Right msg++-- | A message with all-default values encodes to empty bytes.+prop_defaultsProduceEmptyEncoding :: Property+prop_defaultsProduceEmptyEncoding = property $ do+  let msg = AllDefaults (Field "") (Field 0) (Field False)+  encode msg === BS.empty++-- | For any random Person with random Address, roundtrip succeeds.+prop_nestedMessageRoundtrip :: Property+prop_nestedMessageRoundtrip = property $ do+  msg <- forAll genPerson+  decode (encode msg) === Right msg++-- | For any random ByteString, decode @Simple never crashes (always returns Left or Right).+prop_malformedInputDoesNotCrash :: Property+prop_malformedInputDoesNotCrash = property $ do+  bs <- forAll $ Gen.bytes (Range.linear 0 500)+  case decode @Simple bs of+    Left _  -> success+    Right _ -> success++-- | For any Int64, zigzag encode/decode roundtrips.+prop_zigzagInt64Roundtrip :: Property+prop_zigzagInt64Roundtrip = property $ do+  n <- forAll $ Gen.int64 Range.linearBounded+  zigzagDecode (zigzagEncode n) === n+++-- ===================================================================+-- Packed repeated field properties+-- ===================================================================++data Packed = Packed+  { pScores :: Field 1 [Int32]+  } deriving (Show, Eq, Generic)++instance ProtoMessage Packed++-- | For any [Int32], packed encoding then Generics decode roundtrips.+prop_packedRoundtrip :: Property+prop_packedRoundtrip = property $ do+  xs <- forAll $ Gen.list (Range.linear 0 100) (Gen.int32 Range.linearBounded)+  let packed = runProtoBuilder (encodePackedField (Field xs :: Field 1 [Int32]))+  decode @Packed packed === Right (Packed (Field xs))+++-- ===================================================================+-- Map field properties+-- ===================================================================++data WithMap = WithMap+  { wmLabels :: Field 1 (ProtoMap Text Int32)+  } deriving (Show, Eq, Generic)++instance ProtoMessage WithMap++genProtoMap :: Gen (ProtoMap Text Int32)+genProtoMap = do+  kvs <- Gen.list (Range.linear 0 20)+    ((,) <$> genText <*> Gen.int32 Range.linearBounded)+  pure (ProtoMap (Map.fromList kvs))++-- | For any Map Text Int32, encode then decode roundtrips.+prop_mapRoundtrip :: Property+prop_mapRoundtrip = property $ do+  m <- forAll genProtoMap+  let msg = WithMap (Field m)+  decode (encode msg) === Right msg+++-- ===================================================================+-- Main (TemplateHaskell discovery)+-- ===================================================================++tests :: IO Bool+tests = checkParallel $$(discover)++main :: IO ()+main = do+  ok <- tests+  if ok+    then putStrLn "All property tests passed!"+    else do+      putStrLn "Some property tests FAILED!"+      error "Property test failure"
+ test/test.proto view
@@ -0,0 +1,22 @@+syntax = "proto3";+package test;++message SimpleMsg {+  string name = 1;+  int32 age = 2;+  bool active = 3;+}++message NestedMsg {+  string title = 1;+  SimpleMsg author = 2;+}++message RepeatedMsg {+  repeated int32 values = 1;+  repeated string tags = 2;+}++message MapMsg {+  map<string, int32> scores = 1;+}