packages feed

generic-data-1.1.0.2: test/one-liner-surgery.hs

{-# LANGUAGE
    DataKinds,
    DeriveGeneric,
    FlexibleInstances,
    LambdaCase,
    MultiParamTypeClasses,
    ScopedTypeVariables,
    TypeApplications,
    TypeFamilies,
    TypeOperators #-}

{-# OPTIONS_GHC -Wno-name-shadowing #-}

-- Example using one-liner and generic-lens
-- on a synthetic type obtained by surgery.

import Control.Applicative ((<|>))
import Data.Coerce (coerce)
import Data.Functor.Identity (Identity(..))
import Data.Kind (Type)
import GHC.Generics (Generic)
import Text.Read (readMaybe)
import Test.Tasty
import Test.Tasty.HUnit

import Data.Generics.Product (field_)  -- generic-lens
import Generics.OneLiner (nullaryOp, binaryOp)  -- one-liner
import Generics.OneLiner.Binary (gtraverse)

import Generic.Data.Microsurgery (DOnFields)

-- | Toy configuration record type.
data Config = C {
    a :: Int,
    b :: Int,
    c :: String
  } deriving (Eq, Generic, Show)

-- | Applying the 'DOnFields' surgery to get a type isomorphic to:
--
-- > data Config = C {
-- >     a :: Maybe Int,
-- >     b :: Maybe Int,
-- >     c :: Maybe String
-- >   }
--
-- See also "Functor functors" and "Higher-kinded data" for a more general pattern:
--
-- - https://www.benjamin.pizza/posts/2017-12-15-functor-functors.html
-- - https://reasonablypolymorphic.com/blog/higher-kinded-data/
--
type PartialConfig = DOnFields Maybe Config

-- | Example
file1 :: [String]
file1 = [
    "a=11",
    "b=33"
  ]

-- | Example
file2 :: [String]
file2 = [
    "b=2",
    "c=Hello"
  ]

-- | Helper for 'emptyOM' and 'mergeOM' below.
class    (a ~ Maybe (UnMaybe a)) => IsMaybe a
instance (a ~ Maybe (UnMaybe a)) => IsMaybe a

-- | Helper for 'IsMaybe' above.
type family UnMaybe (a :: Type) :: Type where
  UnMaybe (Maybe b) = b

-- |
-- > emptyOM = C {
-- >     a = Nothing,
-- >     b = Nothing,
-- >     c = Nothing
-- >   }
emptyOM :: PartialConfig
emptyOM = nullaryOp @IsMaybe Nothing

-- | Helper for 'parseOM' (actually a function from lens).
--
-- @(l .~ b) s@: set the field of record @s@ focused by lens @l@ to @b@.
--
-- > let f = (field_ @"a" .~ v) in
-- > f (C {a = x, b = y, c = z})
-- >
-- > -- equals --
-- >
-- > C {a = v, b = y, c = z}
--
(.~) :: forall s t a b. ((a -> Identity b) -> s -> Identity t) -> b -> s -> t
(.~) l b = coerce l (const b :: a -> b)

-- | Parse lines of a config file.
parseOM :: [String] -> PartialConfig
parseOM = foldr ($) emptyOM . map (\case
  'a' : '=' : n -> field_ @"a" .~ readMaybe n
  'b' : '=' : n -> field_ @"b" .~ readMaybe n
  'c' : '=' : s -> field_ @"c" .~ Just s
  _ -> id)

-- | Merge two records of 'Maybe' fields, keeping the leftmost 'Just' for each
-- field.
mergeOM :: PartialConfig -> PartialConfig -> PartialConfig
mergeOM = binaryOp @IsMaybe (<|>)

-- | Example
parsedOpts12 :: PartialConfig
parsedOpts12 = parseOM file1 `mergeOM` parseOM file2

-- | Helper for 'validateOM' below.
class    (a ~ Maybe b) => FstIsMaybe a b
instance (a ~ Maybe b) => FstIsMaybe a b

-- | Check that all fields are populated with 'Just' and create a plain
-- 'Config' record. If any field is 'Nothing', returns 'Nothing'.
validateOM :: PartialConfig -> Maybe Config
validateOM = gtraverse @FstIsMaybe id

-- | Example
opts12 :: Maybe Config
opts12 = validateOM parsedOpts12

main :: IO ()
main = defaultMain test

test :: TestTree
test = testGroup "one-liner-surgery"
  [ testCase "opts1" $
      "C {a = Just 11, b = Just 33, c = Nothing}" @=? show (parseOM file1)

  , testCase "opts2" $
      "C {a = Nothing, b = Just 2, c = Just \"Hello\"}" @=? show (parseOM file2)

  , testCase "opts12" $
      Just C {a = 11, b = 33, c = "Hello"} @=? opts12

  , testCase "opts1-incomplete" $
      Nothing @=? validateOM (parseOM file1)

  , testCase "empty" $
      "C {a = Nothing, b = Nothing, c = Nothing}" @=? show emptyOM
  ]