nvim-hs-2.3.2.0: library/Neovim/Test.hs
{-# LANGUAGE RecordWildCards #-}
{- |
Module : Neovim.Test
Description : Testing functions
Copyright : (c) Sebastian Witte
License : Apache-2.0
Maintainer : woozletoff@gmail.com
Stability : experimental
Portability : GHC
-}
module Neovim.Test (
runInEmbeddedNeovim,
runInEmbeddedNeovim',
Seconds (..),
TestConfiguration (..),
-- deprecated
testWithEmbeddedNeovim,
) where
import Neovim
import Neovim.API.Text (nvim_command, vim_command)
import qualified Neovim.Context.Internal as Internal
import Neovim.RPC.Common (RPCConfig, newRPCConfig)
import Neovim.RPC.EventHandler (runEventHandler)
import Neovim.RPC.SocketReader (runSocketReader)
import Control.Monad.Reader (runReaderT)
import Control.Monad.Trans.Resource (runResourceT)
import Data.Default (Default)
import Data.Text (pack)
import GHC.IO.Exception (ioe_filename)
import Neovim.Plugin (startPluginThreads)
import Neovim.Util (oneLineErrorMessage)
import Path (File, Path, toFilePath)
import Prettyprinter (annotate, vsep)
import Prettyprinter.Render.Terminal (Color (..), color)
import System.Process.Typed (
ExitCode (ExitFailure, ExitSuccess),
Process,
createPipe,
getExitCode,
getStdin,
getStdout,
proc,
setStdin,
setStdout,
startProcess,
stopProcess,
waitExitCode,
)
import UnliftIO (Handle, IOException, async, atomically, cancel, catch, newEmptyMVar, putMVar, putTMVar, throwIO, timeout)
import UnliftIO.Concurrent (takeMVar, threadDelay)
-- | Type synonym for 'Word'.
newtype Seconds = Seconds Word
deriving (Show)
microSeconds :: Integral i => Seconds -> i
microSeconds (Seconds s) = fromIntegral s * 1000 * 1000
newtype TestConfiguration = TestConfiguration
{ cancelAfter :: Seconds
}
deriving (Show)
instance Default TestConfiguration where
def =
TestConfiguration
{ cancelAfter = Seconds 2
}
{- | Run a neovim process with @-n --clean --embed@ and execute the
given action that will have access to the started instance.
The 'TestConfiguration' contains sensible defaults.
'env' is the state of your function that you want to test.
-}
runInEmbeddedNeovim :: TestConfiguration -> Plugin env -> Neovim env a -> IO ()
runInEmbeddedNeovim TestConfiguration{..} plugin action =
warnIfNvimIsNotOnPath runTest
where
runTest = do
resultMVar <- newEmptyMVar
let action' = do
result <- action
q <- Internal.asks' Internal.transitionTo
putMVar q Internal.Quit
-- vim_command isn't asynchronous, so we need to avoid waiting
-- for the result of the operation by using 'async' since
-- neovim cannot send a result if it has quit.
_ <- async . void $ vim_command "qa!"
putMVar resultMVar result
(nvimProcess, cleanUp) <- startEmbeddedNvim cancelAfter plugin action'
result <- timeout (microSeconds cancelAfter) (takeMVar resultMVar)
waitExitCode nvimProcess >>= \case
ExitFailure i ->
fail $ "Neovim returned with an exit status of: " ++ show i
ExitSuccess -> case result of
Nothing -> fail "Test timed out"
Just _ -> pure ()
cleanUp
type TransitionHandler a = Internal.Config RPCConfig -> IO a
testTransitionHandler :: IO a -> TransitionHandler ()
testTransitionHandler onInitAction cfg =
takeMVar (Internal.transitionTo cfg) >>= \case
Internal.InitSuccess -> do
void onInitAction
testTransitionHandler onInitAction cfg
Internal.Restart -> do
fail "Restart unexpected"
Internal.Failure e -> do
fail . show $ oneLineErrorMessage e
Internal.Quit -> do
return ()
runInEmbeddedNeovim' :: TestConfiguration -> Neovim () a -> IO ()
runInEmbeddedNeovim' testCfg = runInEmbeddedNeovim testCfg Plugin{environment = (), exports = []}
{-# DEPRECATED testWithEmbeddedNeovim "Use \"runInEmbeddedNeovim def env action\" and open files with nvim_command \"edit file\"" #-}
{- | The same as 'runInEmbeddedNeovim' with the given file opened via @nvim_command "edit file"@.
- This method is kept for backwards compatibility.
-}
testWithEmbeddedNeovim ::
-- | Optional path to a file that should be opened
Maybe (Path b File) ->
-- | Maximum time (in seconds) that a test is allowed to run
Seconds ->
-- | Read-only configuration
env ->
-- | Test case
Neovim env a ->
IO ()
testWithEmbeddedNeovim file timeoutAfter env action =
runInEmbeddedNeovim
def{cancelAfter = timeoutAfter}
Plugin{environment = env, exports = []}
(openTestFile <* action)
where
openTestFile = case file of
Nothing -> pure ()
Just f -> nvim_command $ pack $ "edit " ++ toFilePath f
warnIfNvimIsNotOnPath :: IO a -> IO ()
warnIfNvimIsNotOnPath test = void test `catch` \(e :: IOException) -> case ioe_filename e of
Just "nvim" ->
putDoc . annotate (color Red) $
vsep
[ "The neovim executable 'nvim' is not on the PATH."
, "You may not be testing fully!"
]
_ ->
throwIO e
startEmbeddedNvim ::
Seconds ->
Plugin env ->
Neovim env () ->
IO (Process Handle Handle (), IO ())
startEmbeddedNvim timeoutAfter plugin (Internal.Neovim action) = do
nvimProcess <-
startProcess $
setStdin createPipe $
setStdout createPipe $
proc "nvim" ["-n", "--clean", "--embed"]
cfg <- Internal.newConfig (pure Nothing) newRPCConfig
socketReader <-
async . void $
runSocketReader
(getStdout nvimProcess)
(cfg{Internal.pluginSettings = Nothing})
eventHandler <-
async . void $
runEventHandler
(getStdin nvimProcess)
(cfg{Internal.pluginSettings = Nothing})
let actionCfg = Internal.retypeConfig (environment plugin) cfg
action' = runReaderT (runResourceT action) actionCfg
pluginHandlers <-
startPluginThreads (Internal.retypeConfig () cfg) [wrapPlugin plugin] >>= \case
Left e -> do
putMVar (Internal.transitionTo cfg) $ Internal.Failure e
pure []
Right (funMapEntries, pluginTids) -> do
atomically $
putTMVar
(Internal.globalFunctionMap cfg)
(Internal.mkFunctionMap funMapEntries)
putMVar (Internal.transitionTo cfg) Internal.InitSuccess
pure pluginTids
transitionHandler <- async . void $ do
testTransitionHandler action' cfg
timeoutAsync <- async . void $ do
threadDelay $ microSeconds timeoutAfter
getExitCode nvimProcess >>= maybe (stopProcess nvimProcess) (\_ -> pure ())
let cleanUp =
mapM_ cancel $
[socketReader, eventHandler, timeoutAsync, transitionHandler]
++ pluginHandlers
pure (nvimProcess, cleanUp)