packages feed

bottom-0.2.0.0: src/Data/Encoding/Bottom.hs

{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE StrictData #-}

-- | Encodes and decodes 'Text's to 'Bottom's. For details, see the
-- [Bottom spec](https://github.com/bottom-software-foundation/spec).
module Data.Encoding.Bottom
  ( Bottom,
    unBottom,
    encode,
    decode,
    decode',
  )
where

import Control.DeepSeq (NFData)
import Control.Monad (void)
import Data.Bits (zeroBits)
import Data.ByteString (ByteString)
import qualified Data.ByteString as BS
import qualified Data.Foldable as F
import Data.List (intercalate, unfoldr)
import Data.List.NonEmpty (NonEmpty (..))
import qualified Data.List.NonEmpty as NE
import Data.Text (Text)
import qualified Data.Text as T
import Data.Text.Encoding (decodeUtf8, encodeUtf8)
import Data.Void (Void)
import Data.Word (Word8)
import Text.Megaparsec (Parsec, Token, chunk, eof, manyTill, runParser, someTill, (<|>))
import Text.Megaparsec.Error (ErrorItem (..), ParseError (..), ParseErrorBundle (..), errorBundlePretty)

-- | A 'Bottom' is a wrapper around well-formed, Bottom-encoded 'ByteString'.
-- Its instances are derived from those of 'ByteString'.
newtype Bottom = Bottom ByteString
  deriving (Show, Eq, Ord, Semigroup, Monoid, NFData)

-- | 'unBottom' unwraps the underlying 'ByteString'.
unBottom :: Bottom -> ByteString
unBottom (Bottom bs) = bs

-- Value characters for encoding.
singleton :: Char -> ByteString
singleton = encodeUtf8 . T.singleton

twoHundred :: ByteString
twoHundred = singleton '\x1FAC2'

fifty :: ByteString
fifty = singleton '\x1F496'

ten :: ByteString
ten = singleton '\x2728'

five :: ByteString
five = singleton '\x1F97A'

one :: ByteString
one = singleton '\x002C'

zero :: ByteString
zero = encodeUtf8 $ T.pack ['\x2764', '\xFE0F']

separator :: ByteString
separator = encodeUtf8 $ T.pack ['\x1F449', '\x1F448']

-- Decoding functions.

-- | 'decode' decodes a 'Bottom' into its corresponding Unicode 'Text'.
decode :: Bottom -> Text
decode (Bottom bs) = case decode' bs of
  Right r -> r
  Left err -> error $ "Data.Encoding.Bottom.decode: malformed Bottom: " <> err

type Parser = Parsec Void ByteString

-- | 'decode'' decodes an arbitrary Bottom-encoded 'ByteString' into a 'Text',
-- or returns a parse error message if the 'ByteString' is malformed.
decode' :: ByteString -> Either String Text
decode' bs = case runParser bottomParser "" bs of
  Left err -> Left $ renderError err
  Right r -> Right r
  where
    bottomParser :: Parser Text
    bottomParser = decodeUtf8 . BS.pack <$> (fmap . fmap) toEnum (groupParser `manyTill` eof)

    groupParser :: Parser Int
    groupParser = parseNull <|> parseValues
      where
        parseNull = zeroParser >> separatorParser >> return 0
        parseValues = sum <$> (twoHundredParser <|> fiftyParser <|> tenParser <|> fiveParser <|> oneParser) `someTill` separatorParser

    twoHundredParser :: Parser Int
    twoHundredParser = chunk twoHundred >> return 200

    fiftyParser :: Parser Int
    fiftyParser = chunk fifty >> return 50

    tenParser :: Parser Int
    tenParser = chunk ten >> return 10

    fiveParser :: Parser Int
    fiveParser = chunk five >> return 5

    oneParser :: Parser Int
    oneParser = chunk one >> return 1

    zeroParser :: Parser Int
    zeroParser = chunk zero >> return 0

    separatorParser :: Parser ()
    separatorParser = void $ chunk separator

    -- Custom error messages, because the default error messages print raw
    -- ByteStrings and don't render correctly.
    renderError :: ParseErrorBundle ByteString Void -> String
    renderError ParseErrorBundle {bundleErrors = (TrivialError offset unexpected expected) :| []} =
      unexpectedMessage <> expectedMessage <> " at offset " <> show offset
      where
        renderErrorItem :: ErrorItem (Token ByteString) -> String
        renderErrorItem (Tokens tokens) = T.unpack $ "\"" <> decodeUtf8 (BS.pack $ NE.toList tokens) <> "\""
        renderErrorItem (Label name) = NE.toList name
        renderErrorItem EndOfInput = "end of input"

        unexpectedMessage = case unexpected of
          Just unx -> "unexpected " <> renderErrorItem unx <> ": "
          Nothing -> ""

        expectedMessage = "expected " <> (if length expecteds >= 2 then "one of " else "") <> intercalate ", " expecteds
          where
            expecteds = renderErrorItem <$> F.toList expected
    renderError err = errorBundlePretty err

-- Encoding functions.

-- | 'encode' takes a 'Text', and encodes it into a 'Bottom'. To get at the
-- underlying 'ByteString', unwrap the returned value with 'unBottom'.
encode :: Text -> Bottom
encode = Bottom . BS.concatMap encodeByte . encodeUtf8

encodeByte :: Word8 -> ByteString
encodeByte b
  | b == zeroBits = zero <> separator
  | otherwise = BS.concat (unfoldr encodeByte' (fromEnum b)) <> separator
  where
    encodeByte' :: Int -> Maybe (ByteString, Int)
    encodeByte' n
      | n >= 200 = Just (twoHundred, n - 200)
      | n >= 50 = Just (fifty, n - 50)
      | n >= 10 = Just (ten, n - 10)
      | n >= 5 = Just (five, n - 5)
      | n >= 1 = Just (one, n - 1)
      | n == 0 = Nothing
      | otherwise = error "Data.Encoding.Bottom.encodeByte': impossible: unsigned byte is negative"