packages feed

ron-hs-0.4.0: src/Data/Ron/Serialize.hs

{-# LANGUAGE RecordWildCards #-}

-- | Definitions for serializing values into bytes. When serializing, you must
-- choose a formatting option defined at 'SerializeSettings'
module Data.Ron.Serialize
    ( encode, encodeFile
    , dumps, dumpFile
    -- * Style options
    , SerializeSettings (..)
    , haskellStyle, rustStyle, compactStyle
    , CommaStyle (..)
    -- * Low-level builders
    , ronBuilder
    ) where

import Data.ByteString.Builder (Builder, toLazyByteString, byteString, integerDec, char7, string7, hPutBuilder)
import Data.ByteString.Builder.Prim (primBounded, condB, (>$<), (>*<), liftFixedToBounded, word16HexFixed)
import Data.ByteString.Builder.Scientific (scientificBuilder)
import Data.Char (ord)
import Data.List (intersperse)
import Data.Text (Text)
import Data.Text.Encoding (encodeUtf8, encodeUtf8BuilderEscaped)
import Data.Ron.Class (ToRon (toRon))
import Data.Word (Word8)
import Test.QuickCheck (Arbitrary, arbitrary, chooseEnum)
import System.IO (IOMode (WriteMode), hSetBinaryMode, hSetBuffering, BufferMode (BlockBuffering), withFile)

import qualified Data.ByteString.Lazy as Lazy
import qualified Data.ByteString.Builder.Prim as Prim
import qualified Data.Map.Strict as Map
import qualified Data.Text as Text
import qualified Data.Vector as Vector

import Data.Ron.Value


-- | Style of comma in compound values
data CommaStyle
    = CommaHistoric
    -- ^ Like in json, comma after value without trailing
    | CommaTrailing
    -- ^ Comma after value, including last element
    | CommaLeading
    -- ^ Haskell style, comma at line start
    deriving (Eq, Show, Bounded, Enum)

data SerializeSettings = SerializeSettings
    { commaStyle :: !CommaStyle
    -- ^ How to separate values in compounds (tuples, records, maps, lists)
    , indent :: !Int
    -- ^ Amount of spaces to indent with. Setting this to zero also disables
    -- line breaks
    , singleElementSpecial :: !Bool
    -- ^ When a compound type only contains one element, this compound value is
    -- printed on one line
    , unpackToplevel :: !Bool
    -- ^ Toplevel record and list are unpacked to not include the constructor
    -- and brackets. This is useful for human-consumed files, and looks similar
    -- to yaml but with commas. Warning: ron-rs doesn't support reading this
    , openBracketOnSameLine :: !Bool
    -- ^ For compound types, does the opening bracket go on a new line or stays
    -- on the same line as the constructor
    , closeBracketOnSameLine :: !Bool
    -- ^ For compound types, does the closing bracket go on a new line or stays
    -- on the same line as the last element. Setting this to True is a popular
    -- default in some haskell autoformat programs
    , spaceAfterColon :: !Bool
    -- ^ Should a space character be put after colon in records and maps.
    -- Useful as @False@ for compact representation
    } deriving (Eq, Show)

-- | Style similar to what is produced in haskell with stylish-haskell or
-- hindent.
--
--      * Uses indent size of 4
--
--      * Unpacks top level values
haskellStyle :: SerializeSettings
haskellStyle = SerializeSettings
    { commaStyle = CommaLeading
    , indent = 4
    , singleElementSpecial = True
    , unpackToplevel = True
    , openBracketOnSameLine = False
    , closeBracketOnSameLine = False
    , spaceAfterColon = True
    }

-- | Style similar to what is produced by rustfmt, or by ron-rs itself
rustStyle :: SerializeSettings
rustStyle = SerializeSettings
    { commaStyle = CommaTrailing
    , indent = 4
    , singleElementSpecial = True
    , unpackToplevel = False
    , openBracketOnSameLine = True
    , closeBracketOnSameLine = False
    , spaceAfterColon = True
    }

-- | All whitespace is disabled. Does not unpack toplevel for compatability, so
-- you can set that if you want an even compacter style that can't be read by
-- ron-rs
compactStyle :: SerializeSettings
compactStyle = SerializeSettings
    { commaStyle = CommaHistoric
    , indent = 0
    , singleElementSpecial = True
    , unpackToplevel = False
    , openBracketOnSameLine = True
    , closeBracketOnSameLine = True
    , spaceAfterColon = False
    }

-- | Serialize a value to a lazy bytestring. For settings you can use
-- 'haskellStyle' or 'rustStyle' or 'compactStyle'
encode :: ToRon a => SerializeSettings -> a -> Lazy.ByteString
encode settings = dumps settings . toRon

-- | Serialize a value into a file. For settings you can use
-- 'haskellStyle' or 'rustStyle' or 'compactStyle'
encodeFile :: ToRon a => SerializeSettings -> FilePath -> a -> IO ()
encodeFile settings path = dumpFile settings path . toRon

-- | Serialize a RON value to a lazy bytestring. You probably want to use
-- 'encode' instead
dumps :: SerializeSettings -> Value -> Lazy.ByteString
dumps settings = toLazyByteString . ronBuilder settings

-- | Serialize a RON value into a file. You probably want to use 'encodeFile'
-- instead
dumpFile :: SerializeSettings -> FilePath -> Value -> IO ()
dumpFile settings path value = withFile path WriteMode $ \handle -> do
    -- recommended in builder package
    hSetBinaryMode handle True
    hSetBuffering handle $ BlockBuffering Nothing -- hmm
    hPutBuilder handle $ ronBuilder settings value

-- | The builder producing the serialized representation. You can use this to
-- write ron to outputs not supported by this library, like pipes or conduits
ronBuilder :: SerializeSettings -> Value -> Builder
ronBuilder SerializeSettings {..} = toplevel where
    deeper !lvl = lvl + indent
    nl  = if indent == 0 then mempty else char7 '\n'
    shift lvl = string7 $ replicate lvl ' '
    bracketOpen afterColon lvl c
        | indent == 0  = char7 c
        | openBracketOnSameLine  = spc <> char7 c <> nl <> shift (deeper lvl)
            <> if commaStyle == CommaLeading
                then char7 ' ' <> char7 ' '
                else mempty
        | commaStyle == CommaLeading  =
            nl <> shift (deeper lvl) <> char7 c <> char7 ' '
        | otherwise  = nl <> shift lvl <> char7 c <> nl <> shift (deeper lvl)
            where spc = if afterColon && spaceAfterColon then char7 ' ' else mempty
    bracketClose lvl c
        | indent == 0  = char7 c
        | closeBracketOnSameLine  = char7 ' ' <> char7 c
        | commaStyle == CommaLeading  = nl <> shift (deeper lvl) <> char7 c
        | otherwise  = nl <> shift lvl <> char7 c
    comma lvl = if commaStyle == CommaLeading
        then nl <> shift lvl <> string7 ", "
        else char7 ',' <> nl <> shift lvl
    trailingComma = if commaStyle == CommaTrailing
        then char7 ','
        else mempty
    toplevel = if unpackToplevel
        then \case
            -- Those two looks bad with leading comma, not sure what to do.
            -- Don't want to turn them into trailing/historic comma for
            -- toplevel only, since I personally always forget to put it after
            -- the previous element
            List xs | not (Vector.null xs) -> listContent 0 xs
            Record name xs | Text.null name -> recordContent 0 xs
            v -> go startIndent v
        else go startIndent
        -- special handling for haskell-style: don't indent the topmost block
        where startIndent = if commaStyle == CommaLeading
                then negate indent
                else 0
    --
    go = go' False
    go' !afterColon !lvl = \case
        Integral x -> spc <> integerDec x
        Floating x -> spc <> scientificBuilder x
        Char x -> spc <> char7 '\'' <> encodeChar x <> char7 '\''
        String x -> spc <> char7 '"' <> encodeString x <> char7 '"'
        Unit name -> spc <> if Text.null name
            then string7 "()"
            else fromText name
        List xs
            | null xs -> spc <> string7 "[]"
            | singleElementSpecial && isSimple (List xs)
                -> let !x = Vector.unsafeHead xs
                       open = if indent /= 0 then string7 "[ " else char7 '['
                       close = if indent /= 0 then string7 " ]" else char7 ']'
                   in spc <> open <> go lvl x <> close
            | otherwise -> bracketOpen afterColon lvl '[' <> listContent (deeper lvl) xs <> bracketClose lvl ']'
        Map xs
            | null xs -> spc <> string7 "{}"
            | singleElementSpecial && isSimple (Map xs)
                -> let (!k, !v) = head . Map.toList $ xs
                       open = if indent /= 0 then string7 "{ " else char7 '{'
                       close = if indent /= 0 then string7 " }" else char7 '}'
                   in spc <> open <> go lvl k <> char7 ':' <> go' True lvl v <> close
            | otherwise -> bracketOpen afterColon lvl '{' <> mapContent (deeper lvl) xs <> bracketClose lvl '}'
        Tuple name xs
            | singleElementSpecial && isSimple (Tuple name xs) ->
                let !x = Vector.unsafeHead xs
                    nameB = if Text.null name
                        then mempty
                        else fromText name <> char7 ' '
                    open = if indent /= 0 then string7 "( " else char7 '('
                    close = if indent /= 0 then string7 " )" else char7 ')'
                in spc <> nameB <> open <> go lvl x <> close
            | Text.null name ->
                bracketOpen afterColon lvl '(' <> listContent (deeper lvl) xs <> bracketClose lvl ')'
            | otherwise ->
                spc <> fromText name <> bracketOpen True lvl '(' <> listContent (deeper lvl) xs <> bracketClose lvl ')'
        Record name xs
            | singleElementSpecial && isSimple (Record name xs) ->
                let (!k, !v) = head . Map.toList $ xs
                    nameB = if Text.null name
                        then mempty
                        else fromText name <> char7 ' '
                    open = if indent /= 0 then string7 "( " else char7 '('
                    close = if indent /= 0 then string7 " )" else char7 ')'
                in spc <> nameB <> open <> fromText k <> char7 ':' <> go' True lvl v <> close
            | Text.null name ->
                bracketOpen afterColon lvl '(' <> recordContent (deeper lvl) xs <> bracketClose lvl ')'
            | otherwise ->
                spc <> fromText name <> bracketOpen True lvl '(' <> recordContent (deeper lvl) xs <> bracketClose lvl ')'
        where
            spc = if afterColon && spaceAfterColon then char7 ' ' else mempty
    --
    listContent lvl = (<> trailingComma) . mconcat . intersperse (comma lvl) . map (go lvl) . Vector.toList
    --
    mapContent lvl = (<> trailingComma) . mconcat . intersperse (comma lvl) . map toElem . Map.toList where
        toElem (k, v) = go lvl k <> char7 ':' <> go' True lvl v
    --
    recordContent lvl = (<> trailingComma) . mconcat . intersperse (comma lvl) . map toElem . Map.toList where
        toElem (k, v) = fromText k <> char7 ':' <> go' True lvl v

-- | Can the value be nicely rendered on a single line? True for non-compounds and single-value compounds
isSimple :: Value -> Bool
isSimple (Integral _) = True
isSimple (Floating _) = True
isSimple (Char _) = True
isSimple (String _) = True
isSimple (Unit _) = True
isSimple (List elems) = case Vector.uncons elems of
    Nothing -> True
    Just (x, xs)
        | null xs   -> isSimple x
        | otherwise -> False
isSimple (Map xs)
    | null xs = True
    | length xs == 1 = case Map.toList xs of
        [(k, v)] -> isSimple k && isSimple v
        _ -> False
    | otherwise = False
isSimple (Tuple _ elems) = case Vector.uncons elems of
    Nothing -> True
    Just (x, xs)
        | null xs   -> isSimple x
        | otherwise -> False
isSimple (Record _ xs)
    | null xs = True
    | length xs == 1 = case Map.toList xs of
        [(_, v)] -> isSimple v
        _ -> False
    | otherwise = False

fromText :: Text -> Builder
fromText = byteString . encodeUtf8

encodeString :: Text -> Builder
encodeString = encodeUtf8BuilderEscaped escape
    where
        escape
            = condB (== c2w '\\' ) (ascii2 ('\\','\\'))
            . condB (== c2w '\b' ) (ascii2 ('\\','b' ))
            . condB (== c2w '\f' ) (ascii2 ('\\','f' ))
            . condB (== c2w '\n' ) (ascii2 ('\\','n' ))
            . condB (== c2w '\r' ) (ascii2 ('\\','r' ))
            . condB (== c2w '\t' ) (ascii2 ('\\','t' ))
            . condB (== c2w '\"' ) (ascii2 ('\\','\"' ))
            . condB (>= c2w ' '  ) (liftFixedToBounded Prim.word8)
            $ liftFixedToBounded hexEscape -- fallback for chars < 0x20
        hexEscape = (\c -> ('\\', ('u', fromIntegral c))) >$<
            Prim.char8 >*< Prim.char8 >*< word16HexFixed
        ascii2 cs = liftFixedToBounded $ const cs >$< Prim.char7 >*< Prim.char7
        c2w = fromIntegral . ord :: Char -> Word8

encodeChar :: Char -> Builder
encodeChar = primBounded escape
    where
        escape
            = condB (== '\\' ) (unicode2 ('\\','\\'))
            . condB (== '\b' ) (unicode2 ('\\','b' ))
            . condB (== '\f' ) (unicode2 ('\\','f' ))
            . condB (== '\n' ) (unicode2 ('\\','n' ))
            . condB (== '\r' ) (unicode2 ('\\','r' ))
            . condB (== '\t' ) (unicode2 ('\\','t' ))
            . condB (== '\'' ) (unicode2 ('\\','\'' ))
            . condB (>= ' '  ) Prim.charUtf8
            $ liftFixedToBounded hexEscape -- fallback for chars < 0x20
        hexEscape = (\c -> ('\\', ('u', fromIntegral . ord $ c))) >$<
            Prim.char8 >*< Prim.char8 >*< word16HexFixed
        unicode2 cs = const cs >$< Prim.charUtf8 >*< Prim.charUtf8

--- QuickCheck

instance Arbitrary CommaStyle where
    arbitrary = chooseEnum (minBound, maxBound)

instance Arbitrary SerializeSettings where
    arbitrary = SerializeSettings
        <$> arbitrary
        <*> arbitrary
        <*> arbitrary
        <*> arbitrary
        <*> arbitrary
        <*> arbitrary
        <*> arbitrary