re-xyr / cleff

Fast and concise extensible effects
https://hackage.haskell.org/package/cleff
BSD 3-Clause "New" or "Revised" License
105 stars 6 forks source link

How to store/output effects in tests? #28

Closed JeanHuguesdeRaigniac closed 1 year ago

JeanHuguesdeRaigniac commented 1 year ago

For tests purpose, I try to make an interpreter which stores used commands of an effect. Here I tried with a State but of course result is the same with a Writer.

I had some overlapping instances errors if I stored directly the commands (String from ReadFile is not () from WriteFile), so I added a FSWrapper type to hide their return types. But I still get overlapping problem over the monad now.

Here is a way to reproduce it:

data Filesystem :: Effect where
  ReadFile :: FilePath -> Filesystem m String
  WriteFile :: FilePath -> String -> Filesystem m ()

makeEffect ''Filesystem

data FSWrapper m = S (Filesystem m String) | V (Filesystem m ())

runFS :: Eff (Filesystem : es) a -> Eff es a
runFS =
  fmap fst
    . runState @([FSWrapper Identity]) [] -- Identity is my last attempt to reify m but it doesn't work :(
    . reinterpret \case
      ReadFile path -> do
        void $ modify (S (ReadFile path) :)
        pure "a"
      WriteFile path contents -> modify (V (WriteFile path contents) :)

main :: IO ()
main = do
  let res = runPure $ runFS $ readFile "nonexistent"
  print res

It produces this error:

 app/Main.hs:29:16: error:
    • Overlapping instances for State [FSWrapper m0]
                                :> (State [FSWrapper Identity] : es)
        arising from a use of ‘modify’
      Matching instances:
        two instances involving out-of-scope types
          instance (e :> es) => e :> (e' : es)
            -- Defined at /Users/JEAN-HUGUES/work/cleff/src/Cleff/Internal/Stack.hs:130:10
          instance [overlapping] e :> (e : es)
            -- Defined at /Users/JEAN-HUGUES/work/cleff/src/Cleff/Internal/Stack.hs:127:30
      (The choice depends on the instantiation of ‘m0, es’
       To pick the first instance above, use IncoherentInstances
       when compiling the other instance declarations)
    • In the second argument of ‘($)’, namely
        ‘modify (S (ReadFile path) :)’
      In a stmt of a 'do' block: void $ modify (S (ReadFile path) :)
      In the expression:
        do void $ modify (S (ReadFile path) :)
           pure "a"
   |
29 |         void $ modify (S (ReadFile path) :)
   |                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^

app/Main.hs:31:34: error:
    • Overlapping instances for State [FSWrapper m1]
                                :> (State [FSWrapper Identity] : es)
        arising from a use of ‘modify’
      Matching instances:
        two instances involving out-of-scope types
          instance (e :> es) => e :> (e' : es)
            -- Defined at /Users/JEAN-HUGUES/work/cleff/src/Cleff/Internal/Stack.hs:130:10
          instance [overlapping] e :> (e : es)
            -- Defined at /Users/JEAN-HUGUES/work/cleff/src/Cleff/Internal/Stack.hs:127:30
      (The choice depends on the instantiation of ‘m1, es’
       To pick the first instance above, use IncoherentInstances
       when compiling the other instance declarations)
    • In the expression: modify (V (WriteFile path contents) :)
      In a case alternative:
          WriteFile path contents -> modify (V (WriteFile path contents) :)
      In the first argument of ‘reinterpret’, namely
        ‘\case
           ReadFile path
             -> do void $ modify (S (ReadFile path) :)
                   pure "a"
           WriteFile path contents -> modify (V (WriteFile path contents) :)’
   |
31 |       WriteFile path contents -> modify (V (WriteFile path contents) :)
   |                                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

What am I supposed to do?

re-xyr commented 1 year ago

This problem stems from m being ambiguous in your call to modify (there wasn't any context from which the compiler can infer what m exactly is). Consider supplying the type of the state via TypeApplications:

reinterpret \case
  ReadFile path -> do
    void $ modify @[FSWrapper Identity] (S (ReadFile path) :)
    pure "a"
  WriteFile path contents -> modify @[FSWrapper Identity] (V (WriteFile path contents) :)

(Also there's cleff-plugin that tries to disambiguate cases like this automatically, but it only works up to GHC 9.2; I haven't got the time to make it work with 9.4 yet.)

JeanHuguesdeRaigniac commented 1 year ago

Thanks, that was fast!

My project uses GHC 9.2.5 but I wanted to try on a small scale: this test is done within GHCi on your repo.

JeanHuguesdeRaigniac commented 1 year ago

Do you know if there is a way to avoid this FSWrapper please?

re-xyr commented 1 year ago

Not completely avoid, but this could be less cumbersome:

data FSWrapper = forall m a. Op (Filesystem m a)

This type also gets rid of needing to explicitly supply the state type in your modify.

JeanHuguesdeRaigniac commented 1 year ago

Thanks, it covers well type construction but there seem to be no way to pattern match on the command used:

*Main> test = runPure $ runFS $ readFile "nonexistent"

*Main> [Op cmd] = test
<interactive>:121:5: error:
    • Couldn't match expected type ‘p’
                  with actual type ‘Filesystem m a’
        because type variables ‘m’, ‘a’ would escape their scope
      These (rigid, skolem) type variables are bound by
        a pattern with constructor:
          Op :: forall (m :: Type -> Type) a. Filesystem m a -> FSWrapper,
        in a pattern binding
        at <interactive>:121:2-7
    • In the pattern: Op cmd
      In the pattern: [Op cmd]
      In a pattern binding: [Op cmd] = test

*Main> [Op (ReadFile p :: Filesystem m String)] = test
<interactive>:120:6: error:
    • You cannot bind scoped type variable ‘m’
        in a pattern binding signature
    • In the pattern: ReadFile p :: Filesystem m String
      In the pattern: Op (ReadFile p :: Filesystem m String)
      In the pattern: [Op (ReadFile p :: Filesystem m String)]
<interactive>:120:6: error:
    • Couldn't match type ‘a’ with ‘String’
      ‘a’ is a rigid type variable bound by
        a pattern with constructor:
          Op :: forall (m :: Type -> Type) a. Filesystem m a -> FSWrapper,
        in a pattern binding
        at <interactive>:120:2-39
      Expected type: Filesystem m a
        Actual type: Filesystem m String
    • When checking that the pattern signature: Filesystem m String
        fits the type of its context: Filesystem m a
      In the pattern: ReadFile p :: Filesystem m String
      In the pattern: Op (ReadFile p :: Filesystem m String)
JeanHuguesdeRaigniac commented 1 year ago

For the record, here is another shot at it for State and Writer. Still a bit verbose but more ergonomic. There is certainly better but it will do for now.

module Main where

import Cleff
import Cleff.State
import Cleff.Writer
import Control.Monad (void)
import Data.Functor.Identity
import Prelude hiding (readFile, writeFile)

data Filesystem :: Effect where
  ReadFile :: FilePath -> Filesystem m String
  WriteFile :: FilePath -> String -> Filesystem m ()

makeEffect ''Filesystem

data FSWrapper m = S (Filesystem m String) | V (Filesystem m ())

type FSWrapperI a = FSWrapper Identity

instance Eq (FSWrapper Identity) where
  S (ReadFile path) == S (ReadFile path') = path == path'
  S _ == _ = False
  V (WriteFile path contents) == V (WriteFile path' contents') =
    path == path' && contents == contents'
  V _ == _ = False

-- State
wrap :: Filesystem m a -> FSWrapperI a
wrap (ReadFile path) = S (ReadFile path)
wrap (WriteFile path contents) = V (WriteFile path contents)

runFS :: Eff (Filesystem : es) a -> Eff es [FSWrapper Identity]
runFS =
  fmap snd
    . runState []
    . reinterpret \case
      ReadFile path -> do
        void $ modify (wrap (ReadFile path) :)
        pure "state"
      WriteFile path contents -> modify (wrap (WriteFile path contents) :)

-- Writer
wrap' :: Filesystem m a -> [FSWrapperI a]
wrap' (ReadFile path) = [S (ReadFile path)]
wrap' (WriteFile path contents) = [V (WriteFile path contents)]

runFS' :: Eff (Filesystem : es) a -> Eff es [FSWrapper Identity]
runFS' =
  fmap snd
    . runWriter
    . reinterpret \case
      ReadFile path -> do
        void $ tell $ wrap' (ReadFile path)
        pure "writer"
      WriteFile path contents -> tell $ wrap' (WriteFile path contents)

main :: IO ()
main =
  do
    -- State
    let [V (WriteFile path content)] = runPure $ runFS $ writeFile "path" "state"
    print $ path == "path" && content == "state"

    let cmds = runPure $ runFS $ readFile "nonexistent"
    print $ wrap (ReadFile "nonexistent") `elem` cmds

    -- Writer
    let [S (ReadFile path')] = runPure $ runFS' $ readFile "nonexistent"
    print $ path' == "nonexistent"

    let cmds' =
          runPure $
            runFS' $ do
              content' <- readFile "nonexistent"
              writeFile "path" content'
    print $ wrap (WriteFile "path" "writer") `elem` cmds'
re-xyr commented 1 year ago

Thanks, it covers well type construction but there seem to be no way to pattern match on the command used

You could probably case on it (as opposed to creating a binding) and be careful not to return any value whose type depends on the existential m from the case expression.

Of course, you can probably also just do this for Filesystem, which is a first-order effect whose operations do not depend on the m parameter:

data FsWrapper = forall a. Op (Filesystem Identity a)