mfmts-1.1.0.0: varint/MultiFormats/VarInt.hs
-- | Module : MultiFormats.VarInt
-- Description : Implements multiformat's varints
-- Copyright : Zoey McBride (c) 2026
-- License : AGPL-3.0-or-later
-- Maintainer : zoeymcbride@mailbox.org
-- Stability : experimental
--
-- Implements serialization and extraction for multiformat's unsigned binary
-- varint spec. See: https://github.com/multiformats/unsigned-varint
module MultiFormats.VarInt
( VarInt,
extractVarInt,
serializeVarInt,
maxVarInt,
)
where
import Data.BaseSystem.BinaryTranscoder (BinaryTranscoder)
import Data.BaseSystem.BinaryTranscoder qualified as BXC
import Data.Bits ((.&.), (.<<.), (.>>.), (.|.))
import Data.ByteString.Builder (Builder)
import Data.ByteString.Builder qualified as Builder
import Data.ByteString.Short qualified as SBS
import Data.Word (Word16, Word64, Word8)
import MultiFormats.VarInt.Errors (Error (..), Okay)
-- | VarInt spec uses unsigned 64 bit integers.
type VarInt = Word64
-- | Byte with most significant bit (MSB) set to 1.
msb1 :: Word8
msb1 = 1 .<<. 7
-- | Byte with least significant bit (LSB) set to 1.
lsb1 :: Word8
lsb1 = 1
-- | The value with MSB=1 and LSB=1.
msb1lsb1 :: Word8
msb1lsb1 = msb1 .|. lsb1
-- | The value that extracts all bits but the MSB in a bitwise AND.
mask7lsbs :: Word8
mask7lsbs = msb1 - 1
-- | The maximum value the spec allows is 2^63-1, so a 64-bit integer with bit
-- missing to account for the final byte's terminating MSB bit.
maxVarInt :: VarInt
maxVarInt = (1 .<<. 63) - 1
-- | The maximum # of bytes a VarInt can occupy when encoded.
bytesCapacity :: Int
bytesCapacity = 9
-- | Splits ByteString into it's first occurring VarInt and the remaining
-- ByteString after.
spanVarInt :: (BinaryTranscoder t) => t -> Okay (t, t)
spanVarInt bxc =
-- Collect the bytes with MSB=1.
case BXC.spanBytes isMSB1 bxc of
-- The varint is the leading bytes with the MSB=1 and a final terminating
-- byte with MSB=0.
(msb1s, msb0s)
-- Verify if a final byte exists.
| BXC.null msb0s -> Left MissingTermination
-- The loop invariant ensures &msb1s + 1 has MSB=0, so compute this offset
-- and split the data there.
| otherwise ->
let nulloffset = BXC.lengthBytes msb1s + 1
in Right (BXC.splitAtByte nulloffset bxc)
where
-- Checks if the most-significant bit (MSB) is set to 1
isMSB1 byte = (byte .&. msb1) /= 0
-- | Concats the lower 7 bits from bytes into single int value.
buildVarInt :: (BinaryTranscoder t) => t -> VarInt
buildVarInt = foldr build 0 . BXC.unpackBytes
where
-- Shifts the accumulator 7 bits (1 byte minus MSB value) and ORs the
-- current byte into place, and remove the MSB set bit.
build byte acc = acc .<<. 7 .|. fromIntegral (byte .&. mask7lsbs)
-- | Checks edge case where input is invalid from not being a minimal encoding
-- of varints, (ie, we can fit 2 bytes of 7 bits into 1 byte of 7 bits). The
-- only case of this is when a final 0x8100 should be a 0x79... I think!
{-# INLINE nonMinimal #-}
nonMinimal :: (BinaryTranscoder bxc) => bxc -> Bool
nonMinimal vardata = last16bits == invalidsuffix
where
-- Puts the last two bytes of the transcoder into a Word16.
last16bits = fromIntegral $ BXC.unpackValue (BXC.takeBytesEnd 2 vardata)
-- This value is the same to the value from bytes 0x7F, so it's not minimal
invalidsuffix = (fromIntegral msb1lsb1 :: Word16) .<<. 8
-- | Checks if a VarInt encoded ByteString overflows a Word64.
{-# INLINE aboveCapacity #-}
aboveCapacity :: (BinaryTranscoder t) => t -> Bool
aboveCapacity vardata =
let wholelen = BXC.lengthBytes vardata
in not (1 <= wholelen && wholelen <= bytesCapacity)
-- | Extracts the first minimally encoded varint from a ByteString.
extractVarInt :: (BinaryTranscoder t) => t -> Okay (VarInt, t)
extractVarInt bytes
| BXC.null bytes = Left EmptyBytes
| otherwise =
case spanVarInt bytes of
Left err -> Left err
Right (varbytes, rest)
| aboveCapacity varbytes -> Left BytesOverflow
| nonMinimal varbytes -> Left NotMinimal
| otherwise -> Right (buildVarInt varbytes, rest)
-- | Puts a parsed VarInt (Word64 value) into a Builder of ShortByteString with
-- MSB bits set, and a max size of 9 bytes.
serializeVarInt :: VarInt -> Okay Builder
serializeVarInt varint
| varint > maxVarInt = Left AboveMaxValue
| otherwise =
Right
-- Put the ShortByteString into a ByteString Builder.
. Builder.shortByteString
-- Set the continuation bits in the bytes.
. SBS.unfoldr setMSBs
-- Replace empty bytestrings with a single zero byte.
. replaceNull0
-- Drop bytes until a minimal value is found.
. SBS.dropWhileEnd (== 0)
-- Pack the bytes from varint 7-bit groups.
$ SBS.pack [var7sAt bytepos | bytepos <- [1 .. bytesCapacity]]
where
-- Extracts 1-indexed groups of 7 bits from varint starting from the LSB.
{-# INLINEABLE var7sAt #-}
var7sAt idx = fromIntegral (varint .>>. (7 * (idx - 1))) .&. mask7lsbs
-- Sets MSB=1 for leading bytes in unfoldr.
{-# INLINEABLE setMSBs #-}
setMSBs bytes = do
(top, rest) <- SBS.uncons bytes
Just $
if SBS.null rest
then (top, SBS.empty)
else (top .|. msb1, rest)
-- Replaces a null ByteString with a single zero.
{-# INLINEABLE replaceNull0 #-}
replaceNull0 bytes =
if SBS.null bytes
then SBS.singleton 0
else bytes