packages feed

binrep-0.2.0: src/Binrep/Type/Text.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE CPP #-}

module Binrep.Type.Text
  ( Encoding(..)
  , AsText
  , Encode, encode
  , Decode(..)
  , encodeToRep
  , decodeViaTextICU
  ) where

import Binrep.Type.Common ( Endianness(..) )
import Binrep.Type.ByteString ( Rep )

import Refined
import Refined.Unsafe

import Data.ByteString qualified as B
import Data.Text qualified as Text
import Data.Text ( Text )
import Data.Char qualified as Char
import Data.Text.Encoding qualified as Text
import Data.Either.Combinators qualified as Either

import GHC.Generics ( Generic )
import Data.Data ( Data )

import Data.Typeable ( Typeable, typeRep )

import System.IO.Unsafe qualified
import Control.Exception qualified
import Data.Text.Encoding.Error qualified

import Data.Text.ICU.Convert qualified as ICU

type Bytes = B.ByteString

-- | Character encoding.
--
-- Byte-oriented encodings like ASCII and UTF-8 don't need to worry about
-- endianness. For UTF-16 and UTF-32, the designers decided to allow different
-- endiannesses, rather than saying "codepoints must be X-endian".
data Encoding
  = UTF8
  | UTF16 Endianness
  | UTF32 Endianness
  | ASCII -- ^ 7-bit
  | SJIS
    deriving stock (Generic, Data, Show, Eq)

-- | A string of a given encoding, stored in the 'Text' type.
type AsText (enc :: Encoding) = Refined enc Text

-- | Bytestring encoders for text validated for a given encoding.
class Encode (enc :: Encoding) where
    -- | Encode text to bytes. Internal function, use 'encode'.
    encode' :: Text -> Bytes

instance Encode 'UTF8 where encode' = Text.encodeUtf8

-- | ASCII is a subset of UTF-8, so valid ASCII is valid UTF-8, so this is safe.
instance Encode 'ASCII where encode' = encode' @'UTF8

instance Encode ('UTF16 'BE) where encode' = Text.encodeUtf16BE
instance Encode ('UTF16 'LE) where encode' = Text.encodeUtf16LE
instance Encode ('UTF32 'BE) where encode' = Text.encodeUtf32BE
instance Encode ('UTF32 'LE) where encode' = Text.encodeUtf32LE

instance Encode 'SJIS where encode' = encodeViaTextICU' "Shift-JIS"

-- | Encode some validated text.
encode :: forall enc. Encode enc => AsText enc -> Bytes
encode = encode' @enc . unrefine

-- | Any 'Text' value is always valid UTF-8.
instance Predicate 'UTF8 Text where validate _ _ = success

-- | Any 'Text' value is always valid UTF-16.
instance Typeable e => Predicate ('UTF16 e) Text where validate _ _ = success

-- | Any 'Text' value is always valid UTF-32.
instance Typeable e => Predicate ('UTF32 e) Text where validate _ _ = success

-- | 'Text' must be validated if you want to permit 7-bit ASCII only.
instance Predicate 'ASCII Text where
    validate p t = if   Text.all Char.isAscii t
                   then success
                   else throwRefineOtherException (typeRep p) "not valid 7-bit ASCII"

-- | TODO Unsafely assume all 'Text's are valid Shift-JIS.
instance Predicate 'SJIS Text where validate _ _ = success

class Decode (enc :: Encoding) where
    -- | Decode a 'ByteString' to 'Text' with an explicit encoding.
    --
    -- This is intended to be used with visible type applications.
    decode :: Bytes -> Either String (AsText enc)

instance Decode 'UTF8  where decode = decodeText show Text.decodeUtf8'
instance Decode ('UTF16 'BE) where decode = decodeText show $ wrapUnsafeDecoder Text.decodeUtf16BE
instance Decode ('UTF16 'LE) where decode = decodeText show $ wrapUnsafeDecoder Text.decodeUtf16LE
instance Decode ('UTF32 'BE) where decode = decodeText show $ wrapUnsafeDecoder Text.decodeUtf32BE
instance Decode ('UTF32 'LE) where decode = decodeText show $ wrapUnsafeDecoder Text.decodeUtf32LE

-- Pre-@text-2.0@, @decodeASCII@ generated a warning and ran @decodeUtf8@.
#if MIN_VERSION_text(2,0,0)
instance Decode 'ASCII where decode = decodeText $ wrapUnsafeDecoder Text.decodeASCII
#endif

instance Decode 'SJIS where decode = decodeText id $ decodeViaTextICU' "Shift-JIS"

--------------------------------------------------------------------------------
-- Helpers

-- | Encode some text to a bytestring, asserting that the resulting value is
--   valid for the requested bytestring representation.
--
-- This is intended to be used with visible type applications:
--
-- >>> let Right t = refine @'UTF8 (Text.pack "hi")
-- >>> :t t
-- t :: AsText 'UTF8
-- >>> let Right bs = encodeToRep @'C t
-- >>> :t bs
-- bs :: Refined 'C Bytes
encodeToRep
    :: forall (rep :: Rep) enc
    .  (Encode enc, Predicate rep Bytes)
    => AsText enc
    -> Either RefineException (Refined rep Bytes)
encodeToRep = refine . encode

--------------------------------------------------------------------------------
-- Internal helpers

-- | Helper for decoding a 'Bytes' to a 'Text' tagged with its encoding.
decodeText
    :: forall enc e
    .  (e -> String) -> (Bytes -> Either e Text) -> Bytes
    -> Either String (AsText enc)
decodeText g f = Either.mapBoth g reallyUnsafeRefine . f

-- | Run an unsafe decoder safely.
--
-- Copied from @Data.Text.Encoding.decodeUtf8'@, so should be bulletproof?
wrapUnsafeDecoder
    :: (Bytes -> Text)
    -> Bytes -> Either Data.Text.Encoding.Error.UnicodeException Text
wrapUnsafeDecoder f =
      System.IO.Unsafe.unsafeDupablePerformIO
    . Control.Exception.try
    . Control.Exception.evaluate
    . f

-- | Encode some 'Text' to the given character set using text-icu.
--
-- No guarantees about correctness. Encodings are weird. e.g. Shift JIS's
-- yen/backslash problem is apparently to do with OSs treating it differently.
--
-- Expects a 'Text' that is confirmed valid for converting to the character set.
--
-- The charset must be valid, or it's exception time. See text-icu.
encodeViaTextICU :: String -> Text -> IO B.ByteString
encodeViaTextICU charset t = do
    conv <- ICU.open charset Nothing
    return $ ICU.fromUnicode conv t

encodeViaTextICU' :: String -> Text -> B.ByteString
encodeViaTextICU' charset t =
    System.IO.Unsafe.unsafeDupablePerformIO $ encodeViaTextICU charset t

-- TODO Shitty library doesn't let us say how to handle errors. Apparently, the
-- only solution is to scan through the resulting 'Text' to look for @\SUB@
-- characters, or lie about correctness. Sigh.
decodeViaTextICU :: String -> B.ByteString -> IO (Either String Text)
decodeViaTextICU charset t = do
    conv <- ICU.open charset Nothing
    return $ Right $ ICU.toUnicode conv t

decodeViaTextICU' :: String -> B.ByteString -> Either String Text
decodeViaTextICU' charset t = do
    System.IO.Unsafe.unsafeDupablePerformIO $ decodeViaTextICU charset t