packages feed

rapidhash-0.1.0.0: src/Data/Hash/RapidHash/Types.hs

{-# LANGUAGE LinearTypes #-}
{-# LANGUAGE MagicHash #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}

module Data.Hash.RapidHash.Types (
  -- * Seeds
  RapidSeed (..),
  defaultSeed,

  -- * Hashes
  RapidHash (..),
  pattern RAPIDHASH_V3_PREFIX,
  pattern RAPIDHASH_V3_HEX_LENGTH,

  -- ** Serializing
  rapidHashTextBuilder,
  showRapidHashText,
  showRapidHashBS,
  showsRapidHash,

  -- ** Deserializing
  parseRapidHashText,
  parseRapidHashBS,
  readsRapidHash,
) where

import Control.Monad (when)
import Control.Monad.ST.Strict (ST)
import Data.Binary (Binary)
import Data.Binary qualified as Binary
import Data.Bits (Bits (shiftR, (.&.)))
import Data.ByteString (ByteString)
import Data.ByteString qualified as BS
import Data.ByteString.Char8 qualified as BSC8
import Data.ByteString.Lex.Integral qualified as BS
import Data.Char (isSpace)
import Data.Foldable (for_, length)
import Data.Hashable (Hashable)
import Data.List qualified as L
import Data.Maybe (maybe)
import Data.String (IsString)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.Array qualified as A
import Data.Text.Builder.Linear qualified as TB
import Data.Text.Builder.Linear.Buffer qualified as TBuff
import Data.Text.Builder.Linear.Core qualified as TB
import Data.Text.Read qualified as T
import Data.Word (Word64)
import GHC.Exts (Int (I#), (>#))
import GHC.Generics (Generic)
import Prelude (
  Applicative (pure),
  Either (Left, Right),
  Eq ((==)),
  MonadFail (fail),
  Num ((*), (+), (-)),
  Ord,
  Read (readsPrec),
  ReadS,
  Semigroup ((<>)),
  Show (show, showsPrec),
  ShowS,
  String,
  fromIntegral,
  otherwise,
  showString,
  ($),
  (.),
  (<$>),
  (||),
 )

-- $setup
-- >>> :set -XOverloadedStrings
-- >>> import Data.Text qualified as T

-- | Minimal wrapper around a rapidhash seed.
newtype RapidSeed = RapidSeed Word64
  deriving stock (Generic, Read, Show)
  deriving newtype (Eq, Num, Ord)

-- | rapidhash's default seed is 0 in the reference implementation
defaultSeed :: RapidSeed
defaultSeed = RapidSeed 0

-- | A rapidhash v3 digest.
--
-- The textual form is @rhv3:@ followed by exactly 16 hex digits, always
-- rendered lowercase. Parsing accepts either case (normalizing on
-- re-render), but rejects any other prefix, length, or stray characters.
-- The version tag makes stored digests self-describing: a future
-- rapidhash version bump (which changes outputs) can never be mistaken
-- for a v3 digest.
--
-- === __Examples__
-- >>> RapidHash 0xabc
-- rhv3:0000000000000abc
--
-- >>> Prelude.read "rhv3:0000000000000abc" :: RapidHash
-- rhv3:0000000000000abc
newtype RapidHash = RapidHash Word64
  deriving stock (Generic)
  deriving newtype (Eq, Hashable, Ord)

-- |
-- The prefix used during serialization and deserialization. This is not a universal
-- convention, but an identifier that should help prevent confusing these hashes
-- with others.
--
-- Exported for convenience.
pattern RAPIDHASH_V3_PREFIX :: (IsString a, Eq a) => a
pattern RAPIDHASH_V3_PREFIX = "rhv3:"

-- | The length of the hash part of a valid rapidhash.
--
-- Exported for convenience.
--
-- === __Total length__
--
-- >>> RAPIDHASH_V3_HEX_LENGTH + length (RAPIDHASH_V3_PREFIX :: String)
-- 21
pattern RAPIDHASH_V3_HEX_LENGTH :: (Num a, Eq a) => a
pattern RAPIDHASH_V3_HEX_LENGTH = 16

pattern ZERO_CHAR :: (Num a, Eq a) => a
pattern ZERO_CHAR = 0x30

prefixLen :: Int
prefixLen = length (RAPIDHASH_V3_PREFIX :: String)

instance Show RapidHash where
  showsPrec _ = showsRapidHash

-- |
--
-- === __Examples__
--
-- >>> Prelude.read (show (Prelude.Just (RapidHash 0x1234))) :: Prelude.Maybe RapidHash
-- Just rhv3:0000000000001234
instance Read RapidHash where
  readsPrec _ = readsRapidHash

-- |
-- Efficient binary serialization and deserialization, with a just-good-enough tag.
--
-- Encodes in 9 bytes: One byte for the tag, 8 for the 'Word64'.
instance Binary RapidHash where
  put (RapidHash h) = do
    Binary.putWord8 3
    Binary.put h
  {-# INLINE put #-}
  get = do
    tag <- Binary.getWord8
    case tag of
      3 -> RapidHash <$> Binary.get
      _ -> fail "did not find magic tag for rapidhash"
  {-# INLINE get #-}

----------------------------------------------------------------------------------------------------
-- Serializing
----------------------------------------------------------------------------------------------------

-- |
-- Efficiently render a 'RapidHash' as a 'TB.Builder'
--
-- === __Examples__
--
-- >>> rapidHashTextBuilder (RapidHash 0x0)
-- "rhv3:0000000000000000"
--
-- >>> rapidHashTextBuilder (RapidHash 0xabcd3)
-- "rhv3:00000000000abcd3"
--
-- >>> rapidHashTextBuilder (RapidHash 0xfffff2381)
-- "rhv3:0000000fffff2381"
--
-- === __Prefixing__
--
-- >>> RAPIDHASH_V3_PREFIX `T.isPrefixOf` showRapidHashText (RapidHash 0x0)
-- True
rapidHashTextBuilder :: RapidHash -> TB.Builder
rapidHashTextBuilder h = TB.Builder \b -> appendPaddedHashHexBuf (b TBuff.|># "rhv3:"#) h
{-# INLINEABLE rapidHashTextBuilder #-}

-- |
-- Efficiently render a 'RapidHash' as 'Text'.
--
-- Prefer 'rapidHashTextBuilder' when composing.
showRapidHashText :: RapidHash -> Text
showRapidHashText = TB.runBuilder . rapidHashTextBuilder
{-# INLINEABLE showRapidHashText #-}

-- |
-- Efficiently render a 'RapidHash' as 'ByteString'. If your text blob is utf-8, it may be worth
-- composing using 'TB.Builder' and extracting with 'TB.runBuilderBS' yourself.
--
-- The resulting 'ByteString' is utf-8 encoded, using 'TB.runBuilderBS'
--
-- See 'rapidHashTextBuilder'
showRapidHashBS :: RapidHash -> ByteString
showRapidHashBS = TB.runBuilderBS . rapidHashTextBuilder
{-# INLINEABLE showRapidHashBS #-}

-- |
-- Somewhat inefficiently render a 'RapidHash' as a 'String'.
--
-- Present for debugging purposes. Goes through 'rapidHashTextBuilder'
showsRapidHash :: RapidHash -> ShowS
showsRapidHash h = showString $ T.unpack (showRapidHashText h)

-- |
-- This is a specialized reimplementation of 'TBuff.|>&' from Andrew Lelechenko's
-- text-builder-linear that additionally pads with zeros.
appendPaddedHashHexBuf :: TBuff.Buffer %1 -> RapidHash -> TBuff.Buffer
appendPaddedHashHexBuf buffer (RapidHash h) =
  -- appendBounded function preallocates a given length, and takes a callback that
  -- mutates the array and returns how many characters it wrote.
  TB.appendBounded
    RAPIDHASH_V3_HEX_LENGTH
    ( \dst off -> do
        endOff <- unsafeAppendHexW64 dst (off + RAPIDHASH_V3_HEX_LENGTH - 1) h

        -- Core lowering here is consistently fused, but if there's a regression I'd
        -- look here first.
        for_ @[] [off .. endOff] \i ->
          A.unsafeWrite dst i ZERO_CHAR

        pure RAPIDHASH_V3_HEX_LENGTH
    )
    buffer
{-# INLINEABLE appendPaddedHashHexBuf #-}

unsafeAppendHexW64 :: A.MArray s -> Int -> Word64 -> ST s Int
unsafeAppendHexW64 marr = go
 where
  go !off = \case
    0 -> pure off
    m -> do
      let nibble = m .&. 0x0F
      A.unsafeWrite marr off $ hex (fromIntegral nibble)
      unsafeAppendHexW64 marr (off - 1) (m `shiftR` 4)

  -- Vendored from text-builder-linear - branchless conversion
  hex n@(I# n#) = fromIntegral $ ZERO_CHAR + n + I# (n# ># 9#) * (0x60 - 0x39)
{-# INLINEABLE unsafeAppendHexW64 #-}

----------------------------------------------------------------------------------------------------
-- Deserializing
----------------------------------------------------------------------------------------------------

-- |
-- Efficiently parse a 'RapidHash' from a 'Text'.
--
-- == __Examples__
--
-- >>> parseRapidHashText "rhv3:deadbeefdeadbeef"
-- Right rhv3:deadbeefdeadbeef
--
-- >>> parseRapidHashText "rhv3:abracadabratoomany"
-- Left "rapidhash hashes should have a length of exactly 16, but found length 18"
--
-- >>> parseRapidHashText "rhv3:nothexnothexnoth"
-- Left "input does not start with a hexadecimal digit"
--
-- >>> parseRapidHashText "rhv3:tooshort"
-- Left "rapidhash hashes should have a length of exactly 16, but found length 8"
--
-- >>> parseRapidHashText "noprefix"
-- Left "missing required prefix \"rhv3:\": \"noprefix\""
--
-- >>> parseRapidHashText "rhv3:abcdefabcdefabcg"
-- Left "leftovers after parsing hash: \"g\""
--
-- >>> parseRapidHashText "rhv3:0xadbeefdeadbeef"
-- Left "input does not start with a hexadecimal digit"
parseRapidHashText :: Text -> Either String RapidHash
parseRapidHashText t = do
  mhash <- guardPrefix (T.splitAt prefixLen t)
  guardLength (T.length mhash)
  -- T.hexadecimal helpfully strips 0x/0X, which is not so helpful for our purposes
  let pre = T.take 2 mhash
  when (pre == "0x" || pre == "0X") $ Left "input does not start with a hexadecimal digit"
  (hash, rest) <- T.hexadecimal mhash
  guardLeftovers rest
  pure $ RapidHash hash
{-# INLINEABLE parseRapidHashText #-}

-- |
-- Efficiently parse a 'RapidHash' from a 'ByteString'.
--
-- == __Examples__
--
-- >>> parseRapidHashBS "rhv3:deadbeefdeadbeef"
-- Right rhv3:deadbeefdeadbeef
--
-- >>> parseRapidHashBS "rhv3:abracadabratoomany"
-- Left "rapidhash hashes should have a length of exactly 16, but found length 18"
--
-- >>> parseRapidHashBS "rhv3:nothexnothexnoth"
-- Left "Could not parse hexadecimal from \"nothexnothexnoth\""
--
-- >>> parseRapidHashBS "rhv3:tooshort"
-- Left "rapidhash hashes should have a length of exactly 16, but found length 8"
--
-- >>> parseRapidHashBS "noprefix"
-- Left "missing required prefix \"rhv3:\": \"noprefix\""
--
-- >>> parseRapidHashBS "rhv3:abcdefabcdefabcg"
-- Left "leftovers after parsing hash: \"g\""
--
-- >>> parseRapidHashBS "rhv3:0xadbeefdeadbeef"
-- Left "leftovers after parsing hash: \"xadbeefdeadbeef\""
parseRapidHashBS :: ByteString -> Either String RapidHash
parseRapidHashBS b = do
  mhash <- guardPrefix (BS.splitAt prefixLen b)
  guardLength (BS.length mhash)
  (hash, rest) <- maybe (badHex mhash) Right (BS.readHexadecimal mhash)
  guardLeftovers rest
  pure $ RapidHash hash
 where
  badHex mhash = Left $ "Could not parse hexadecimal from \"" <> BSC8.unpack mhash <> "\""
{-# INLINEABLE parseRapidHashBS #-}

-- |
-- Somewhat inefficiently parse a 'RapidHash' from a 'String'.
--
-- Present for debugging purposes. Goes through 'parseRapidHashText'. Unlike the other
-- parse functions, this allows trailing characters.
--
-- === __Examples__
-- >>> readsRapidHash "rhv3:0000000000000abcdagk"
-- [(rhv3:0000000000000abc,"dagk")]
readsRapidHash :: ReadS RapidHash
readsRapidHash s =
  let (this, rest) = L.splitAt (prefixLen + RAPIDHASH_V3_HEX_LENGTH) (L.dropWhile isSpace s)
   in case parseRapidHashText (T.pack this) of
        Right parsed -> [(parsed, rest)]
        Left _ -> []

guardPrefix :: (IsString a, Show a, Semigroup a, Eq a) => (a, a) -> Either String a
guardPrefix = \case
  (RAPIDHASH_V3_PREFIX, mhash) -> Right mhash
  (pre, post) -> Left $ "missing required prefix \"" <> RAPIDHASH_V3_PREFIX <> "\": " <> show (pre <> post)

guardLength :: (Show a, Eq a, Num a) => a -> Either String ()
guardLength len
  | len == RAPIDHASH_V3_HEX_LENGTH = Right ()
  | otherwise = Left badLength
 where
  badLength =
    "rapidhash hashes should have a length of exactly "
      <> show @Int RAPIDHASH_V3_HEX_LENGTH
      <> ", but found length "
      <> show len

guardLeftovers :: (Show a, Eq a, IsString a) => a -> Either String ()
guardLeftovers = \case
  "" -> Right ()
  bad ->
    Left $
      "leftovers after parsing hash: "
        <> show bad