resp (empty) → 1.0.0
raw patch · 6 files changed
+846/−0 lines, 6 filesdep +QuickCheckdep +basedep +bytestring
Dependencies added: QuickCheck, base, bytestring, ghc-prim, resp, scanner, tasty, tasty-hunit, tasty-quickcheck, utf8-string
Files
- CHANGELOG.md +5/−0
- LICENSE +28/−0
- README.md +10/−0
- resp.cabal +59/−0
- src/Data/RESP.hs +369/−0
- test/Main.hs +375/−0
+ CHANGELOG.md view
@@ -0,0 +1,5 @@+# Revision history for resp++## 1.0.0 -- YYYY-mm-dd++* First version. Released on an unsuspecting world.
+ LICENSE view
@@ -0,0 +1,28 @@+BSD 3-Clause License++Copyright (c) 2024, Owen Shepherd++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.
+ README.md view
@@ -0,0 +1,10 @@+# resp++[](https://www.haskell.org/) [](https://github.com/414owen/neresp/actions/workflows/haskell-ci.yml) [](https://hackage.haskell.org/package/resp) [](https://github.com/414owen/resp/blob/master/LICENSE)++This is a [RESP3](https://redis.io/docs/reference/protocol-spec/) parser,+implemented as a non-backtracking incremental scanner, using the+[`scanner`](https://hackage.haskell.org/package/scanner) library.++It aims to parse valid RESP3 messages as fast as possible, with+very little emphasis on error messages.
+ resp.cabal view
@@ -0,0 +1,59 @@+cabal-version: 3.0+name: resp+version: 1.0.0+license: BSD-3-Clause+license-file: LICENSE+author: Owen Shepherd+maintainer: owen@owen.cafe+category: Data+build-type: Simple+synopsis : A fast, non-backtracking parser for the redis RESP3 protocol+description: RESP is redis' serialization protocol. This package provides a+ lightweight and correct parser for RESP3, which can be used+ incrementally (eg. over a network connection).+extra-doc-files: CHANGELOG.md+ , README.md+tested-with: GHC==9.6.2+ , GHC==9.4.5+ , GHC==9.2.8+ , GHC==9.0.2+ , GHC==8.10.7+ , GHC==8.8.4+ , GHC==8.6.5+ , GHC==8.4.4+ , GHC==8.2.2+ , GHC==8.0.2+ , GHC==7.10.3+ , GHC==7.8.4+ , GHC==7.6.3+ , GHC==7.4.2++common warnings+ ghc-options: -Wall++library+ import: warnings+ exposed-modules: Data.RESP+ build-depends: base >=4.3 && <5+ , bytestring >= 0.9 && < 0.13+ , scanner >= 0.1 && < 0.4+ if(impl(ghc < 7.6))+ build-depends: ghc-prim > 0.1 && < 0.3+ hs-source-dirs: src+ default-language: Haskell98++test-suite resp-test+ import: warnings+ default-language: Haskell98+ type: exitcode-stdio-1.0+ hs-source-dirs: test+ main-is: Main.hs+ build-depends: base >=4.3 && <5+ , bytestring >= 0.9 && < 0.13+ , QuickCheck >= 2.1 && < 3+ , resp+ , scanner >= 0.1 && < 0.4+ , tasty >= 0.1 && < 1.6+ , tasty-hunit >= 0.1 && < 0.11+ , tasty-quickcheck >= 0.1 && < 0.11+ , utf8-string >= 0.3 && < 2
+ src/Data/RESP.hs view
@@ -0,0 +1,369 @@+{-# LANGUAGE CPP #-}+#if __GLASGOW_HASKELL__ >= 800+{-# LANGUAGE ApplicativeDo #-}+#endif+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TupleSections #-}+{-# LANGUAGE DeriveGeneric #-}++module Data.RESP+ ( RespReply(..)+ , RespExpr(..)+ , parseReply+ , 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)++-- This type synonym was introduced in bytestring 0.11.2.0+type LazyByteString = BSL.ByteString++#if MIN_VERSION_bytestring(0,10,0)+lazyBsToStrict :: LazyByteString -> ByteString+lazyBsToStrict = BSL.toStrict+#else+lazyBsToStrict :: LazyByteString -> ByteString+lazyBsToStrict = BS.concat . BSL.toChunks+#endif++-- | Top-level resp reply.+-- Cannot be nested.+data RespReply+ = RespPush !ByteString ![RespExpr]+ | RespExpr !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 `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 `HashMap`, because+-- that would involve imposing our choice of data structure on the caller.+-- They might want to use `HashMap`, `Map`, or just use the `lookup`+-- function.+--+-- Given these choices, our purview is simple: Parse the text protocol+-- into a Haskell datatype, maintaining all useful information, and not+-- imposing our taste onto the caller.+data RespExpr+ = RespString !ByteString+ | RespBlob !ByteString+ | RespStreamingBlob !LazyByteString+ | 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++-- Top level RESP item+parseReply :: Scanner RespReply+parseReply = do+ c <- Scanner.anyChar8+ case c of+ '>' -> parsePush+ _ -> RespExpr <$> parseExpression' c++-- Non-top-level resp item+parseExpression :: Scanner RespExpr+parseExpression = Scanner.anyChar8 >>= parseExpression'++-- Non-top-level resp item, 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 RespReply+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)+ -> (LazyByteString -> 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'
+ test/Main.hs view
@@ -0,0 +1,375 @@+{-# LANGUAGE CPP #-}+{-# LANGUAGE OverloadedStrings #-}++module Main (main) where++#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.RESP (RespReply(..), RespExpr(..))+import qualified Data.ByteString.UTF8 as BSU+import qualified Data.RESP as R3+-- import qualified Data.Text.Encoding as T+-- import qualified Data.Text as T+-- import Data.Text (Text)+import qualified Data.ByteString as BS+import qualified Data.ByteString.Char8 as BS8+import qualified Data.ByteString.Lazy as BSL+import Scanner+import Test.Tasty+import Test.Tasty.HUnit+import Test.Tasty.QuickCheck (testProperty, Arbitrary(..), Gen, (===))+import qualified Test.Tasty.QuickCheck as QC++arbText :: Gen ByteString+arbText = BSU.fromString <$> arbitrary++arbBs :: Gen ByteString+arbBs = BS.pack <$> arbitrary++arbBsl :: Gen BSL.ByteString+arbBsl = BSL.pack <$> arbitrary++shrinkBs :: ByteString -> [ByteString]+shrinkBs = fmap BS.pack . shrink . BS.unpack++shrinkBsl :: BSL.ByteString -> [BSL.ByteString]+shrinkBsl = fmap BSL.pack . shrink . BSL.unpack++halfArbitrary :: Arbitrary a => Int -> Gen a+halfArbitrary n = QC.resize (n `div` 2) arbitrary++genLine :: Gen ByteString+genLine = fmap BSU.fromString $ QC.listOf $ QC.suchThat arbitrary (not . (`elem` ("\r\n" :: String)))++-- If you want access to this instance, please make a PR+-- to create a cabal sublibrary called `resp-quickcheck`.+-- This will avoid adding `quickcheck` as a dependency of+-- the main `resp` library.+instance Arbitrary RespExpr where+ arbitrary = QC.sized $ \n -> case n of+ _ | n <= 1 -> QC.oneof+ [ RespString <$> genLine+ , RespBlob <$> arbBs+ , RespStreamingBlob <$> arbBsl+ , RespStringError <$> genLine+ , RespBlobError <$> arbBs+ , RespInteger <$> arbitrary+ , pure RespNull+ , RespBool <$> arbitrary+ , RespDouble <$> arbitrary+ , RespVerbatimString <$> arbText+ , RespVerbatimMarkdown <$> arbText+ , RespBigInteger <$> arbitrary+ ]+ _ -> QC.oneof+ [ RespString <$> genLine+ , RespBlob <$> arbBs+ , RespStreamingBlob <$> arbBsl+ , RespStringError <$> genLine+ , RespBlobError <$> arbBs+ , RespInteger <$> arbitrary+ , pure RespNull+ , RespBool <$> arbitrary+ , RespDouble <$> arbitrary+ , RespVerbatimString <$> arbText+ , RespVerbatimMarkdown <$> arbText+ , RespBigInteger <$> arbitrary+ , RespArray <$> halfArbitrary n+ , RespMap <$> halfArbitrary n+ , RespSet <$> halfArbitrary n+ , RespAttribute <$> halfArbitrary n <*> halfArbitrary n+ ]++ shrink expr = case expr of+ RespString a -> RespString <$> shrinkBs a+ RespBlob a -> RespBlob <$> shrinkBs a+ RespStreamingBlob a -> RespStreamingBlob <$> shrinkBsl a+ RespStringError a -> RespStringError <$> shrinkBs a+ RespBlobError a -> RespBlobError <$> shrinkBs a+ RespArray a -> RespArray <$> shrink a+ RespInteger a -> RespInteger <$> shrink a+ RespBool a -> RespBool <$> shrink a+ RespDouble a -> RespDouble <$> shrink a+ RespVerbatimString a -> RespVerbatimString <$> shrinkBs a+ RespVerbatimMarkdown a -> RespVerbatimMarkdown <$> shrinkBs a+ RespBigInteger a -> RespBigInteger <$> shrink a+ RespMap a -> RespMap <$> shrink a+ RespSet a -> RespSet <$> shrink a+ RespNull -> []+ RespAttribute a b -> RespAttribute <$> shrink a <*> shrink b++instance Arbitrary RespReply where+ arbitrary = QC.oneof+ [ RespPush <$> arbBs <*> arbitrary+ , RespExpr <$> arbitrary+ ]+ shrink reply = case reply of+ RespPush a b -> RespPush <$> shrinkBs a <*> shrink b+ RespExpr a -> RespExpr <$> shrink a++showBs :: Show a => a -> ByteString+showBs = BSU.fromString . show++eol :: ByteString+eol = "\r\n"++toStrictBs :: BSL.ByteString -> ByteString+toStrictBs = BS.concat . BSL.toChunks++encodeExpr :: RespExpr -> ByteString+encodeExpr = BS.concat . encodeExpr'++encodeExpr' :: RespExpr -> [ByteString]+encodeExpr' e = case e of + RespString txt -> ["+", txt, eol]+ RespBlob bs -> ["$", showBs $ BS.length bs, eol, bs, eol]+ RespStreamingBlob bs+ | BSL.null bs -> ["$?\r\n;0\r\n"]+ | otherwise -> ["$?\r\n", ";", showBs $ BSL.length bs, eol, toStrictBs bs, "\r\n;0\r\n"]+ RespStringError txt -> ["-", txt, eol]+ RespBlobError bs -> ["!", showBs $ BS.length bs, eol, bs, eol]+ RespArray els -> ["*", showBs $ length els, eol] <> concatMap encodeExpr' els+ RespInteger n -> [":", showBs $ n, eol]+ RespNull -> ["_\r\n"]+ RespBool True -> ["#t\r\n"]+ RespBool False -> ["#f\r\n"]+ RespDouble n -> [",", showBs n, eol]+ RespVerbatimString txt -> let bs = txt in+ ["=", showBs $ 4 + BS.length bs, eol, "txt:", bs, eol]+ RespVerbatimMarkdown txt -> let bs = txt in+ ["=", showBs $ 4 + BS.length bs, eol, "mkd:", bs, eol]+ RespBigInteger n -> ["(", showBs n, eol]+ RespMap els -> ["%", showBs $ length els, eol] <> concatMap encodeTup els+ RespSet els -> ["~", showBs $ length els, eol] <> concatMap encodeExpr' els+ RespAttribute attrs expr ->+ ["|", showBs $ length attrs, eol] <> concatMap encodeTup attrs <> encodeExpr' expr++encodeTup :: (RespExpr, RespExpr) -> [ByteString]+encodeTup (a, b) = concatMap encodeExpr' [a, b]++encodeReply :: RespReply -> ByteString+encodeReply repl = BS.concat $ case repl of+ RespPush t msgs -> [">", showBs $ succ $ length msgs, eol, "$", showBs $ BS.length t, eol, t, eol]+ <> concatMap encodeExpr' msgs+ RespExpr e -> encodeExpr' e++parseExpr :: ByteString -> Either String RespExpr+parseExpr = scanOnly R3.parseExpression++parseReply :: ByteString -> Either String RespReply+parseReply = scanOnly R3.parseReply++testStr :: ByteString -> ByteString -> Assertion+testStr bs expected = parseExpr bs @?= Right (RespString expected)++testStreamingBlob :: ByteString -> ByteString -> Assertion+testStreamingBlob bs expected = parseExpr bs @?= Right (RespStreamingBlob $ BSL.fromChunks [expected])++testArray :: ByteString -> [RespExpr] -> Assertion+testArray bs expected = parseExpr bs @?= Right (RespArray expected)++testDouble :: ByteString -> Double -> Assertion+testDouble bs d = parseExpr bs @?= Right (RespDouble d)++testDouble' :: ByteString -> (Double -> Assertion) -> Assertion+testDouble' bs f = case parseExpr bs of+ Right (RespDouble d) -> f d+ _ -> assertFailure "Expected to parse into a double"++blobProperties :: ByteString -> String -> (ByteString -> RespExpr) -> TestTree+blobProperties leader prefix constr = testProperty "quickcheck" $ \str -> let bs = BSU.fromString $ prefix <> str in+ parseExpr (leader <> BS8.pack (show $ BS8.length bs) <> "\r\n" <> bs <> "\r\n") === Right (constr $ BS8.drop (length prefix) bs)++blobTestCases :: ByteString -> (ByteString -> RespExpr) -> [TestTree]+blobTestCases leader constr =+ [ testCase "empty" $ parseExpr (leader <> "0\r\n\r\n") @?= Right (constr "")+ , testCase "simple" $ parseExpr (leader <> "7\r\ntest me\r\n") @?= Right (constr "test me")+ , testCase "multiline" $ parseExpr (leader <> "15\r\ntest me\r\nline 2\r\n") @?= Right (constr "test me\r\nline 2")+ , testCase "unicode" $ parseExpr (leader <> "11\r\n( ͡° ͜ʖ ͡°)\r\n") @?= Right (constr "( ͡° ͜ʖ ͡°)")+ , testCase "not enough bytes" $ parseExpr (leader <> "10\r\nhello\r\n") @?= Left "No more input"+ , testCase "too many bytes" $ parseExpr (leader <> "2\r\nhello\r\n") @?= Left "Expected '\\r', but got 'l'"+ , blobProperties leader "" constr+ ]++integerTestCases :: (Arbitrary a, Num a, Show a) => ByteString -> (a -> RespExpr) -> [TestTree]+integerTestCases prefix constr =+ -- We currently parse a zero-digit integer as 0,+ -- even though it's technically an invalid response+ -- in the spec. Sometimes being lenient is efficient.+ [ testCase "empty" $ parseExpr (prefix <> "\r\n") @?= Left "No more input"+ , testCase "zero" $ parseExpr (prefix <> "0\r\n") @?= Right (constr 0)+ , testCase "one" $ parseExpr (prefix <> "1\r\n") @?= Right (constr 1)+ , testCase "forty-two" $ parseExpr (prefix <> "42\r\n") @?= Right (constr 42)+ , testCase "forty-two" $ parseExpr (prefix <> "-42\r\n") @?= Right (constr (-42))+ , testProperty "quickcheck" $ \i -> parseExpr (prefix <> BS8.pack (show i) <> "\r\n") == Right (constr i)+ ]++main :: IO ()+main = defaultMain $ testGroup "Tests"+ [ testGroup "simple string"+ [ testCase "empty string" $ testStr "+\r\n" ""+ , testCase "nonempty string" $ testStr "+test me\r\n" "test me"+ ]++ , testGroup "simple blobs" $ blobTestCases "$" RespBlob+ , testGroup "blob errors" $ blobTestCases "!" RespBlobError++ , testGroup "verbatim strings"+ [ testGroup "text" $ pure $ blobProperties "=" "txt:" RespVerbatimString+ , testGroup "markdown" $ pure $ blobProperties "=" "mkd:" RespVerbatimMarkdown+ ]++ , testGroup "streaming blob parts"+ [ testCase "empty" $ testStreamingBlob "$?\r\n;0\r\n\r\n" ""+ , testCase "one-part" $ testStreamingBlob "$?\r\n;3\r\nwow\r\n;0\r\n" "wow"+ , testCase "two-part" $ testStreamingBlob "$?\r\n;4\r\nhell\r\n;7\r\no world\r\n;0\r\n" "hello world"+ , testCase "three-part" $ testStreamingBlob "$?\r\n;4\r\nhell\r\n;3\r\no w\r\n;4\r\norld\r\n;0\r\n" "hello world"+ ]++ , testGroup "integer" $ integerTestCases ":" RespInteger+ , testGroup "big integer" $ integerTestCases "(" RespBigInteger++ , testGroup "null"+ [ testCase "RESP2 bulk string null" $ parseExpr "$-1\r\n" @?= Right RespNull+ ]++ , testGroup "array"+ [ testCase "empty" $ parseExpr "*0\r\n" @?= Right (RespArray [])+ , testCase "[hello, world]"+ $ testArray+ "*2\r\n$5\r\nhello\r\n$5\r\nworld\r\n"+ [RespBlob "hello", RespBlob "world"]+ , testCase "[1 .. 3]"+ $ testArray+ "*3\r\n:1\r\n:2\r\n:3\r\n"+ $ RespInteger <$> [1..3]+ , testCase "heterogeneous"+ $ testArray+ "*5\r\n:1\r\n:2\r\n:3\r\n:4\r\n$5\r\nhello\r\n"+ $ map RespInteger [1..4] <> [RespBlob "hello"]++ -- from website+ , testCase "nested"+ $ testArray+ "*2\r\n*3\r\n:1\r\n:2\r\n:3\r\n*2\r\n+Hello\r\n-World\r\n"+ $ RespArray <$> [RespInteger <$> [1..3], [RespString "Hello", RespStringError "World"]]++ -- from markdown+ , testCase "nested 2"+ $ testArray+ "*2\r\n*3\r\n:1\r\n$5\r\nhello\r\n:2\r\n#f\r\n"+ [RespArray [RespInteger 1, RespBlob "hello", RespInteger 2], RespBool False]+++ -- website: "Null arrays"+ , testCase "null" $ parseExpr "*-1\r\n" @?= Right RespNull++ -- website: "Null elements in arrays"+ , testCase "null element"+ $ testArray+ "*3\r\n$5\r\nhello\r\n$-1\r\n$5\r\nworld\r\n"+ [RespBlob "hello", RespNull, RespBlob "world"]++ -- from markdown spec+ , testCase "streaming"+ $ testArray+ "*?\r\n:1\r\n:2\r\n:3\r\n.\r\n"+ $ RespInteger <$> [1..3]++ ]++ , testCase "null" $ parseExpr "_\r\n" @?= Right RespNull++ , testGroup "boolean"+ [ testCase "true" $ parseExpr "#t\r\n" @?= Right (RespBool True)+ , testCase "false" $ parseExpr "#f\r\n" @?= Right (RespBool False)+ ]++ , testGroup "double"+ [ testCase "from int" $ testDouble ",42\r\n" 42+ , testCase "with decimal pt" $ testDouble ",42.12\r\n" 42.12+ , testCase "with exponent" $ testDouble ",42.12e2\r\n" 4212+ , testCase "with positive exponent" $ testDouble ",42.12e+2\r\n" 4212+ , testCase "negative with negative exponent" $ testDouble ",-42.12e-2\r\n" (-0.4212)++ , testCase "inf" $ testDouble' ",inf\r\n" $ assertBool "is infinite" . isInfinite+ , testCase "-inf" $ testDouble' ",-inf\r\n" $ \d -> do+ assertBool "is infinite" $ isInfinite d+ assertBool "== negate (1/0)" $ d == negate (1 / 0)+ , testCase "nan" $ testDouble' ",nan\r\n" $ assertBool "is NaN" . isNaN++ -- Looks like we can also parse all `show`n doubles+ , testProperty "quickcheck" $ \d -> parseExpr ("," <> BS8.pack (show d) <> "\r\n") == Right (RespDouble d)+ ]++ , testGroup "map"+ [ testCase "empty" $ parseExpr "%0\r\n" @?= Right (RespMap [])+ , testCase "simple" $ parseExpr "%2\r\n+first\r\n:1\r\n+second\r\n:2\r\n"+ @?= Right (RespMap [(RespString "first", RespInteger 1), (RespString "second", RespInteger 2)])++ , testGroup "streamed"+ [ testCase "empty" $ parseExpr "%?\r\n.\r\n" @?= Right (RespMap [])+ , testCase "streamed" $ parseExpr "%?\r\n+a\r\n:1\r\n+b\r\n:2\r\n.\r\n"+ @?= Right (RespMap [(RespString "a", RespInteger 1), (RespString "b", RespInteger 2)])+ ]+ ]+ , testGroup "set"+ [ testCase "empty" $ parseExpr "~0\r\n" @?= Right (RespSet [])+ , testCase "nonempty" $ parseExpr "~5\r\n+orange\r\n+apple\r\n#t\r\n:100\r\n:999\r\n"+ @?= Right (RespSet [RespString "orange", RespString "apple", RespBool True, RespInteger 100, RespInteger 999])+ , testGroup "streamed"+ [ testCase "empty" $ parseExpr "~?\r\n.\r\n" @?= Right (RespSet [])+ , testCase "streamed" $ parseExpr "~?\r\n+a\r\n:1\r\n+b\r\n:2\r\n.\r\n"+ @?= Right (RespSet [RespString "a", RespInteger 1, RespString "b", RespInteger 2])+ ]+ ]++ , testGroup "attribute"+ [ testCase "empty" $ parseExpr "~0\r\n" @?= Right (RespSet [])+ -- from markdown spec+ , testCase "nonempty" $ parseExpr "|1\r\n+key-popularity\r\n%2\r\n$1\r\na\r\n,0.1923\r\n$1\r\nb\r\n,0.0012\r\n*2\r\n:2039123\r\n:9543892\r\n"+ @?= Right (+ RespAttribute+ [ ( RespString "key-popularity"+ , RespMap+ [ ( RespBlob "a"+ , RespDouble 0.1923+ )+ , ( RespBlob "b"+ , RespDouble 0.0012+ )+ ]+ )+ ]+ (RespArray+ [ RespInteger 2039123+ , RespInteger 9543892+ ])+ )+ ]++ , testGroup "push"+ -- from markdown spec+ [ testCase "empty" $ parseReply ">1\r\n+test\r\n\r\n" @?= Right (RespPush "test" [])+ , testCase "simple message type" $ parseReply ">3\r\n+message\r\n+somechannel\r\n+this is the message\r\n"+ @?= Right (RespPush "message" [RespString "somechannel", RespString "this is the message"])+ , testCase "blob string els" $ parseReply ">3\r\n$7\r\nmessage\r\n$6\r\nsecond\r\n$5\r\nHello\r\n"+ @?= Right (RespPush "message" [RespBlob "second", RespBlob "Hello"])+ ]++ , testProperty "roundtrip expr" $ \ex -> parseExpr (encodeExpr ex) === Right ex+ , testProperty "roundtrip reply" $ \reply -> parseReply (encodeReply reply) === Right reply+ ]