packages feed

resp-2.0.0: src/Data/RESP.hs

{-# LANGUAGE CPP               #-}
#if __GLASGOW_HASKELL__ >= 800
{-# LANGUAGE ApplicativeDo     #-}
#endif
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections     #-}
{-# LANGUAGE DeriveGeneric     #-}

{-|
RESP is the wire protocol that Redis uses.
The latest version is RESP3, which at time of writing
seems relatively complete, but may still be evolving.

This module parses the entire RESP3 spec (as of 2024-01-26),
but also parses some invalid RESP forms that Redis may return,
eg `-nan`, and parses RESP2 forms that have been removed from
the spec (eg `$-1\r\n`).
-}

module Data.RESP
  ( RespMessage(..)
  , RespExpr(..)
  , parseMessage
  , parseExpression
  ) where

import qualified Data.ByteString       as BS
import qualified Data.ByteString.Char8 as BS8
import qualified Data.ByteString.Lazy  as BSL
import qualified Scanner               as Scanner

#if !MIN_VERSION_base(4,8,0)
import Data.Functor
import Control.Applicative
#endif

#if !MIN_VERSION_base(4,11,0)
# if MIN_VERSION_base(4,9,0)
import Data.Semigroup
import Data.Monoid (mempty)
# else
import Data.Monoid ((<>), mempty)
# endif
#endif

import Data.ByteString      (ByteString)
import Data.Char            (digitToInt)
import Data.Int             (Int64)
import Scanner              (Scanner)
import Control.Monad        (when, replicateM)
import GHC.Generics         (Generic)

#if MIN_VERSION_bytestring(0,10,0)
lazyBsToStrict :: BSL.ByteString -> ByteString
lazyBsToStrict = BSL.toStrict
#else
lazyBsToStrict :: BSL.ByteString -> ByteString
lazyBsToStrict = BS.concat . BSL.toChunks
#endif

-- | A message from the server (eg. Redis) to the client.
-- This can be a push message (for pub/sub), or a reply to a command
-- issued by the client.
data RespMessage
  = RespPush !ByteString ![RespExpr]
  | RespReply !RespExpr
  deriving (Show, Eq, Ord, Generic)

-- | RESP3 Expression.
--
-- This descriminates the difference between RespString and RespBlob,
-- even though both contain bytestrings, in order to not throw away
-- information. A caller might care whether the response was delivered
-- with "+", or "$".
--
-- We do not, however descriminate between the different encodings of
-- null. As far as I can tell, these are considered a mistake in the
-- previous versions of the RESP spec, and clients should treat the
-- different encodings the same.
--
-- Why don't we parse `RespString` into `Data.Text.Text`? Well, the caller might
-- not actually need to decode it into text, and so we let the caller
-- decide. This way, we don't have to deal with encoding errors.
--
-- Similarly, we don't parse a `RespMap` into a `Data.HashMap.HashMap`,
-- because that would involve imposing our choice of data structure on
-- the caller. The caller might want to use `Data.HashMap.HashMap`,
-- `Data.Map.Map`, iterate over the elements, or just use the `lookup`
-- function.
data RespExpr
  = RespString !ByteString
  | RespBlob !ByteString
  | RespStreamingBlob !BSL.ByteString
  | RespStringError !ByteString
  | RespBlobError !ByteString
  | RespArray ![RespExpr]
  | RespInteger !Int64
  | RespNull
  | RespBool !Bool
  | RespDouble !Double
  | RespVerbatimString !ByteString
  | RespVerbatimMarkdown !ByteString
  | RespBigInteger !Integer
  | RespMap ![(RespExpr, RespExpr)]
  | RespSet ![RespExpr]
  | RespAttribute ![(RespExpr, RespExpr)] RespExpr
  deriving (Show, Eq, Ord, Generic)

data MessageSize
  = MSVariable
  | MSFixed Int

data NullableMessageSize
  = NMSVariable
  | NMSMinusOne
  | NMSFixed Int

-- | Parse a RESP3 message
parseMessage :: Scanner RespMessage
parseMessage = do
  c <- Scanner.anyChar8
  case c of
    '>' -> parsePush
    _ -> RespReply <$> parseExpression' c

-- | Parse a RESP3 expression
parseExpression :: Scanner RespExpr
parseExpression = Scanner.anyChar8 >>= parseExpression'

-- | Parse a RESP3 expression, taking its first char as a parameter
parseExpression' :: Char -> Scanner RespExpr
parseExpression' c = case c of
  '$' -> parseBlob
  '+' -> parseString
  '-' -> parseStringError
  ':' -> RespInteger <$> parseInteger
  '*' -> parseArray RespArray
  '_' -> RespNull <$ parseEol 
  '#' -> RespBool . (== 't') <$> Scanner.anyChar8 <* parseEol
  ',' -> parseDouble
  '!' -> parseBlobError
  '=' -> parseVerbatimString
  '(' -> RespBigInteger <$> parseInteger
  '%' -> RespMap <$> parseMap
  '~' -> parseArray RespSet
  '|' -> RespAttribute <$> parseMap <*> parseExpression
  _ -> fail $ "Unknown expression prefix: " <> show c

parsePush :: Scanner RespMessage
parsePush = do
  len <- parseMessageSize
  RespPush <$> parsePushType <*> replicateM (pred len) parseExpression

parsePushType :: Scanner ByteString
parsePushType = do
  c <- Scanner.anyChar8
  -- No idea whether this can be a simple string or not,
  -- the spec isn't specific enough.
  --
  -- The spec doesn't say that the push type *can't* be a
  -- streamed blob string (or null), but let's face it, only a sadist would
  -- return one of those. I'll try to get these possibilities excluded from
  -- the spec, but in the meantime, we're going to have to parse all the
  -- blobstrings.
  case c of
    '$' -> parseBlob' id lazyBsToStrict $ fail "Push message type can't be null"
    '+' -> parseLine
    _ -> fail "Invalid push message type"

parseMap :: Scanner [(RespExpr, RespExpr)]
parseMap = do
  len <- parseComplexMessageSize
  case len of
    MSFixed n -> replicateM n parseTwoEls
    MSVariable -> parseVarMapPairs

-- See https://github.com/redis/redis-specifications/blob/master/protocol/RESP3.md#streamed-aggregated-data-types
parseVarMapPairs :: Scanner [(RespExpr, RespExpr)]
parseVarMapPairs = do
  c <- Scanner.anyChar8
  case c of
    '.' -> [] <$ parseEol
    _ -> (:) <$> ((,) <$> parseExpression' c <*> parseExpression) <*> parseVarMapPairs

parseTwoEls :: Scanner (RespExpr, RespExpr)
parseTwoEls = (,) <$> parseExpression <*> parseExpression

-- See: https://github.com/redis/redis-specifications/issues/25
--    , https://github.com/redis/redis-specifications/issues/23
parseVerbatimString :: Scanner RespExpr
parseVerbatimString = do
  len <- parseMessageSize
  entireBlob <- Scanner.take len
  let body = BS8.drop 4 entireBlob
  parseEol
  case BS8.take 3 entireBlob of
    "txt" -> pure $ RespVerbatimString body
    "mkd" -> pure $ RespVerbatimMarkdown body
    _ -> fail "Unknown verbatim string type"

-- I suspect that this can't be streamed, or null
-- See: https://github.com/redis/redis-specifications/issues/23
parseBlobError :: Scanner RespExpr
parseBlobError = do
  len <- parseMessageSize
  RespBlobError <$> Scanner.take len <* parseEol

bsContains :: Char -> ByteString -> Bool
bsContains c = BS8.any (== c)

-- Scanning to NaN is a function so that we don't
-- feel guilty about inlining the patterns
parseLineAsNaN :: Scanner Double
parseLineAsNaN = (0 / 0) <$ parseLine

parseLineAsInf :: Scanner Double
parseLineAsInf = (1 / 0) <$ parseLine

-- (inf|-inf|nan|(+|-)?\d+(\.\d+)?([eE](+|-)?\d+))
--
-- Due to Redis bugs prior to 7.2, we also have to deal with
-- /(-)?nan(\(.*\))?/i, even though they're not part of the
-- RESP spec...
parseDouble :: Scanner RespExpr
parseDouble = do
  c <- Scanner.anyChar8
  RespDouble <$> case c of
    '+' -> go1 =<< Scanner.anyChar8
    '-' -> fmap negate $ go1 =<< Scanner.anyChar8
    'i' -> do
      -- Note: We're not validating that the rest of the line
      -- is actually "nf", because `,i` uniquely determines the
      -- set of valid responses.
      parseLineAsInf
    'n' -> parseLineAsNaN
    'N' -> parseLineAsNaN
    _ -> go1 c

  where
    -- takes first non-sign char of the significand
    go1 :: Char -> Scanner Double
    go1 'i' = parseLineAsInf
    go1 'n' = parseLineAsNaN
    go1 'N' = parseLineAsNaN
    go1 c1 = fromRational <$> do
      decStr <- Scanner.takeWhileChar8 $ not . (`bsContains` ".\reE")
      let dec = parseNatural1 c1 decStr :: Integer
      c2 <- Scanner.anyChar8
      case c2 of
        '\r' -> fromIntegral dec <$ expectChar '\n' 
        '.' -> do
          decStr1 <- Scanner.takeWhileChar8 $ not . (`bsContains` "\reE")
          let dec1 = fromIntegral (parseNatural' dec decStr1) / (10 ^ BS.length decStr1) :: Rational
          c3 <- Scanner.anyChar8
          case c3 of
            '\r' -> dec1 <$ expectChar '\n'
            _ {- c3 `elem` "eE" -} -> go2 dec1
        _ {- c3 `elem` "eE" -} -> go2 $ fromIntegral dec

    -- from first char of exponent (after [eE])
    go2 :: Rational -> Scanner Rational
    go2 n = do
      c <- Scanner.anyChar8
      (negExp, exponent') <- case c of
        '-' -> (True,) . parseNatural <$> parseLine
        '+' -> (False,) . parseNatural <$> parseLine
        _ {- isDigit c -} -> (False,) . parseNatural1 c <$> parseLine
      let expMul = fromIntegral (10 ^ (exponent' :: Integer) :: Integer) :: Rational
      pure $ if negExp then n / expMul else n * expMul

parseNatural :: Integral a => ByteString -> a
parseNatural = parseNatural' 0

parseNatural' :: Integral a => a -> ByteString -> a
parseNatural' = BS8.foldl' (\a b -> a * 10 + fromIntegral (digitToInt b))

parseNatural1 :: Integral a => Char -> ByteString -> a
parseNatural1 = parseNatural' . fromIntegral . digitToInt

-- RESP2 calls these 'multi bulk'
-- RESP3 calls it an 'array'
--
-- This is used to parse arrays and sets, meaning that we parse
-- "~-1\r\n" as RespNull, although this isn't a valid form in the spec.
parseArray :: ([RespExpr] -> RespExpr) -> Scanner RespExpr
parseArray construct = do
  messageSize <- parseComplexNullableMessageSize
  case messageSize of
    NMSFixed n -> construct <$> replicateM n parseExpression
    NMSMinusOne -> pure RespNull
    NMSVariable -> construct <$> parseVarArrayItems

-- See https://github.com/redis/redis-specifications/blob/master/protocol/RESP3.md#streamed-aggregated-data-types
parseVarArrayItems :: Scanner [RespExpr]
parseVarArrayItems = do
  c <- Scanner.anyChar8
  case c of
    '.' -> [] <$ parseEol
    _ -> (:) <$> parseExpression' c <*> parseVarArrayItems

-- RESP2 calls these 'bulk strings'
-- RESP3 calls them 'blob strings' (in the markdown, on the website they're still 'bulk strings')
parseBlob :: Scanner RespExpr
parseBlob = parseBlob' RespBlob RespStreamingBlob $ pure RespNull

-- general case for something that's pretty blobstring-like
parseBlob'
  :: (ByteString -> a)
  -> (BSL.ByteString -> a)
  -> Scanner a
  -> Scanner a
parseBlob' strictConstr lazyConstr nullConstr = do
  ms <- parseComplexNullableMessageSize
  case ms of
    NMSFixed n -> strictConstr <$> Scanner.take n <* parseEol
    NMSVariable -> lazyConstr . BSL.fromChunks <$> streamingBlobParts
    NMSMinusOne -> nullConstr

parseMessageSize :: Scanner Int
parseMessageSize = parseNatural <$> parseLine

-- Used for blobs and arrays
parseComplexNullableMessageSize :: Scanner NullableMessageSize
parseComplexNullableMessageSize = do
  line <- parseLine
  case line of
    "?" -> pure NMSVariable
    "-1" -> pure NMSMinusOne
    _ -> pure $ NMSFixed $ parseNatural line

-- Used for maps, attributes, sets
parseComplexMessageSize :: Scanner MessageSize
parseComplexMessageSize = do
  line <- parseLine
  case line of
    "?" -> pure MSVariable
    _ -> pure $ MSFixed $ parseNatural line

streamingBlobParts :: Scanner [ByteString]
streamingBlobParts = do
  expectChar ';'
  ms <- parseMessageSize
  case ms of
    0 -> pure mempty
    n -> (:) <$> Scanner.take n <* parseEol <*> streamingBlobParts

parseString :: Scanner RespExpr
parseString = RespString <$> parseLine

-- Cautious interpretation, until we can clarify that the
-- error tag is mandatory.
-- https://github.com/redis/redis-specifications/issues/24
parseStringError :: Scanner RespExpr
parseStringError = RespStringError <$> parseLine

parseInteger :: Integral a => Scanner a
parseInteger = do
  c <- Scanner.anyChar8
  case c of
    '+' -> parseNatural <$> parseLine
    '-' -> negate . parseNatural <$> parseLine
    _ -> parseNatural1 c <$> parseLine

parseLine :: Scanner ByteString
parseLine = Scanner.takeWhileChar8 (/= '\r') <* parseEol

expectChar :: Char -> Scanner ()
expectChar c = do
  d <- Scanner.anyChar8
  when (c /= d) $ fail $ "Expected " <> show c <> ", but got " <> show d

parseEol :: Scanner ()
parseEol = do
  expectChar '\r'
  expectChar '\n'