packages feed

ribosome-test-0.4.0.0: lib/Ribosome/Test/Embed.hs

module Ribosome.Test.Embed where

import Chiasma.Test.Tmux (withProcessWait)
import Control.Concurrent.Async.Lifted (async, cancel, race)
import Control.Concurrent.Lifted (fork)
import Control.Exception.Lifted (bracket, try)
import Control.Monad.Trans.Resource (runResourceT)
import qualified Data.Map.Strict as Map (fromList, toList, union)
import Hedgehog (TestT)
import Hedgehog.Internal.Property (mkTestT, runTestT)
import Neovim (Neovim, Object)
import Neovim.API.Text (vim_command)
import qualified Neovim.Context.Internal as Internal (
  Config,
  Neovim(Neovim),
  StateTransition(Failure, InitSuccess, Quit),
  globalFunctionMap,
  mkFunctionMap,
  newConfig,
  pluginSettings,
  retypeConfig,
  transitionTo,
  )
import Neovim.Main (standalone)
import Neovim.Plugin (Plugin(Plugin), startPluginThreads)
import Neovim.Plugin.Internal (NeovimPlugin, wrapPlugin)
import Neovim.RPC.Common (RPCConfig, newRPCConfig)
import Neovim.RPC.EventHandler (runEventHandler)
import Neovim.RPC.SocketReader (runSocketReader)
import System.Directory (makeAbsolute)
import System.Exit (ExitCode)
import System.Log.Logger (Priority(ERROR), setLevel, updateGlobalLogger)
import qualified System.Posix.Signals as Signal (killProcess, signalProcess)
import System.Process (getPid)
import System.Process.Typed (
  Process,
  ProcessConfig,
  createPipe,
  getExitCode,
  getStdin,
  getStdout,
  proc,
  setStdin,
  setStdout,
  startProcess,
  stopProcess,
  unsafeProcessHandle,
  )

import Ribosome.Api.Option (rtpCat)
import Ribosome.Control.Exception (tryAny)
import Ribosome.Control.Monad.Ribo (NvimE)
import Ribosome.Control.Ribosome (Ribosome(Ribosome), newRibosomeTMVar)
import qualified Ribosome.Data.ErrorReport as ErrorReport (ErrorReport(..))
import Ribosome.Error.Report.Class (ReportError(errorReport))
import Ribosome.Nvim.Api.IO (vimSetVar)
import Ribosome.Plugin.RpcHandler (RpcHandler(native))
import Ribosome.System.Time (sleep, sleepW)
import Ribosome.Test.Orphans ()

type Runner m = ∀ a . TestConfig -> m a -> m a

newtype Vars =
  Vars (Map Text Object)
  deriving (Eq, Show)
  deriving newtype (Default, Semigroup, Monoid)

-- |left biased
varsUnion :: Vars -> Vars -> Vars
varsUnion (Vars v1) (Vars v2) =
  Vars (Map.union v1 v2)

varsFromList :: [(Text, Object)] -> Vars
varsFromList =
  Vars . Map.fromList

data TestConfig =
  TestConfig {
    tcPluginName :: Text,
    tcExtraRtp :: Text,
    tcLogPath :: FilePath,
    tcTimeout :: Word,
    tcCmdline :: Maybe [Text],
    tcCmdArgs :: [Text],
    tcVariables :: Vars
  }

instance Default TestConfig where
  def = TestConfig "ribosome" "test/u/fixtures/rtp" "test/u/temp/log" 10 def def def

defaultTestConfigWith :: Text -> Vars -> TestConfig
defaultTestConfigWith name vars =
  def { tcPluginName = name, tcVariables = vars }

defaultTestConfig :: Text -> TestConfig
defaultTestConfig name = defaultTestConfigWith name def

setVars :: ∀ m e. NvimE e m => Vars -> m ()
setVars (Vars vars) =
  traverse_ set (Map.toList vars)
  where
    set :: (Text, Object) -> m ()
    set =
      uncurry vimSetVar

setupPluginEnv ::
  MonadIO m =>
  NvimE e m =>
  TestConfig ->
  m ()
setupPluginEnv (TestConfig _ rtp _ _ _ _ vars) = do
  absRtp <- liftIO $ makeAbsolute (toString rtp)
  rtpCat (toText absRtp)
  setVars vars

killPid :: Integral a => a -> IO ()
killPid =
  void . tryAny . Signal.signalProcess Signal.killProcess . fromIntegral

killProcess :: Process i o e -> IO ()
killProcess prc = do
  void $ try @_ @SomeException do
    let handle = unsafeProcessHandle prc
    mayPid <- getPid handle
    traverse_ killPid mayPid

testNvimProcessConfig :: TestConfig -> ProcessConfig Handle Handle ()
testNvimProcessConfig TestConfig {..} =
  setStdin createPipe . setStdout createPipe . proc "nvim" . fmap toString $ args <> tcCmdArgs
  where
    args = fromMaybe defaultArgs tcCmdline
    defaultArgs = ["--embed", "-n", "-u", "NONE", "-i", "NONE"]

startHandlers ::
  MonadIO m =>
  Handle ->
  Handle ->
  TestConfig ->
  Internal.Config RPCConfig ->
  m (IO ())
startHandlers stdoutHandle stdinHandle TestConfig{} nvimConf = do
  socketReader <- liftIO (run runSocketReader stdoutHandle)
  eventHandler <- liftIO (run runEventHandler stdinHandle)
  atomically $ putTMVar (Internal.globalFunctionMap nvimConf) (Internal.mkFunctionMap [])
  let stopEventHandlers = traverse_ @[] cancel [socketReader, eventHandler]
  return stopEventHandlers
  where
    run runner hand = async . void $ runner hand emptyConf
    emptyConf = nvimConf { Internal.pluginSettings = Nothing }

startStdioHandlers ::
  MonadIO m =>
  NvimProc ->
  TestConfig ->
  Internal.Config RPCConfig ->
  m (IO ())
startStdioHandlers prc =
  startHandlers (getStdout prc) (getStdin prc)

runNeovimThunk :: Internal.Config e -> Neovim e a -> IO a
runNeovimThunk cfg (Internal.Neovim thunk) =
  runReaderT (runResourceT thunk) cfg

type NvimProc = Process Handle Handle ()

waitQuit :: NvimProc -> IO (Maybe ExitCode)
waitQuit prc =
  wait 30
  where
    wait :: Int -> IO (Maybe ExitCode)
    wait 0 = return Nothing
    wait count = do
      code <- getExitCode prc
      case code of
        Just a -> return $ Just a
        Nothing -> do
          sleep 0.1
          wait $ count - 1

quitNvim :: Internal.Config e -> NvimProc -> IO ()
quitNvim testCfg prc = do
  quitThread <- async $ runNeovimThunk testCfg quit
  result <- waitQuit prc
  case result of
    Just _ -> return ()
    Nothing -> killProcess prc
  cancel quitThread
  where
    quit = vim_command "qall!"

shutdownNvim :: Internal.Config e -> NvimProc -> IO () -> IO ()
shutdownNvim _ prc stopEventHandlers = do
  stopEventHandlers
  killProcess prc

runTest ::
  MonadIO m =>
  MonadFail m =>
  ReportError e =>
  RpcHandler e env n =>
  MonadBaseControl IO m =>
  TestConfig ->
  Internal.Config env ->
  n a ->
  m a
runTest TestConfig{..} testCfg thunk = do
  race (sleepW tcTimeout) (liftIO (runNeovimThunk testCfg (runExceptT $ native thunk))) >>= \case
    Right (Right a) -> pure a
    Right (Left e) -> fail . toString . unlines . ErrorReport._log . errorReport $ e
    Left _ -> fail $ "test exceeded timeout of " <> show tcTimeout <> " seconds"

runEmbeddedNvim ::
  MonadIO m =>
  MonadFail m =>
  MonadBaseControl IO m =>
  RpcHandler e env n =>
  ReportError e =>
  TestConfig ->
  env ->
  n a ->
  NvimProc ->
  m a
runEmbeddedNvim conf ribo thunk prc = do
  nvimConf <- liftIO (Internal.newConfig (pure Nothing) newRPCConfig)
  let testCfg = Internal.retypeConfig ribo nvimConf
  bracket (startStdioHandlers prc conf nvimConf) (liftIO . shutdownNvim testCfg prc) (const $ runTest conf testCfg thunk)

withProcessTerm ::
  MonadIO m =>
  MonadBaseControl IO m =>
  ProcessConfig stdin stdout stderr ->
  (Process stdin stdout stderr -> m a) ->
  m a
withProcessTerm config =
  bracket (startProcess config) (try @_ @SomeException . stopProcess)

runEmbedded ::
  MonadIO m =>
  MonadFail m =>
  MonadBaseControl IO m =>
  RpcHandler e env n =>
  ReportError e =>
  TestConfig ->
  env ->
  n a ->
  m a
runEmbedded conf ribo thunk = do
  let pc = testNvimProcessConfig conf
  withProcessWait pc $ runEmbeddedNvim conf ribo thunk

unsafeEmbeddedSpec ::
  MonadIO m =>
  MonadFail m =>
  MonadBaseControl IO m =>
  RpcHandler e env n =>
  ReportError e =>
  Runner n ->
  TestConfig ->
  env ->
  n a ->
  m a
unsafeEmbeddedSpec runner conf s spec =
  runEmbedded conf s $ runner conf spec

unsafeEmbeddedSpecR ::
  MonadIO m =>
  MonadFail m =>
  ReportError e =>
  MonadBaseControl IO m =>
  RpcHandler e (Ribosome env) n =>
  Runner n ->
  TestConfig ->
  env ->
  n a ->
  m a
unsafeEmbeddedSpecR runner conf env spec = do
  tv <- newRibosomeTMVar env
  let ribo = Ribosome (tcPluginName conf) tv
  unsafeEmbeddedSpec runner conf ribo spec

runPlugin ::
  MonadIO m =>
  MonadBaseControl IO m =>
  Handle ->
  Handle ->
  [Neovim () NeovimPlugin] ->
  Internal.Config c ->
  m (MVar Internal.StateTransition)
runPlugin evHandlerHandle sockreaderHandle plugins baseConf = do
  liftIO (updateGlobalLogger "Neovim.Plugin" (setLevel ERROR))
  rpcConf <- liftIO newRPCConfig
  let conf = Internal.retypeConfig rpcConf baseConf
  ehTid <- (fmap void . async . liftIO) (runEventHandler evHandlerHandle conf { Internal.pluginSettings = Nothing })
  srTid <- (fmap void . async . liftIO) (runSocketReader sockreaderHandle conf)
  void $ fork $ liftIO $ startPluginThreads (Internal.retypeConfig () baseConf) plugins >>= \case
    Left e -> do
      putMVar (Internal.transitionTo conf) $ Internal.Failure e
      standalone [ehTid, srTid] conf
    Right (funMapEntries, pluginTids) -> do
      atomically $ putTMVar (Internal.globalFunctionMap conf) (Internal.mkFunctionMap funMapEntries)
      putMVar (Internal.transitionTo conf) Internal.InitSuccess
      standalone (srTid : ehTid : pluginTids) conf
  return (Internal.transitionTo conf)

inTestT ::
  ∀ n m a .
  TestT n a ->
  (∀ x . n x -> m x) ->
  TestT m a
inTestT ma f =
  mkTestT (f (runTestT ma))

integrationSpec ::
  ∀ n m e env a .
  NvimE e n =>
  MonadIO n =>
  MonadIO m =>
  MonadFail m =>
  ReportError e =>
  RpcHandler e env n =>
  MonadBaseControl IO m =>
  TestConfig ->
  Plugin env ->
  TestT n a ->
  TestT m a
integrationSpec conf plugin@(Plugin env _) thunk =
  inTestT thunk \ na ->
    withProcessTerm (testNvimProcessConfig conf) (run na)
  where
    run :: ∀ x . n x -> Process Handle Handle () -> m x
    run na prc = do
      nvimConf <- liftIO (Internal.newConfig (pure Nothing) (pure env))
      bracket (acquire prc nvimConf) release (const $ runTest conf nvimConf (setupPluginEnv conf *> na))
    acquire prc nvimConf =
      liftIO (runPlugin (getStdin prc) (getStdout prc) [wrapPlugin plugin] nvimConf <* sleep 0.5)
    release transitions =
      tryPutMVar transitions Internal.Quit *> sleep 0.5

integrationSpecDef ::
  NvimE e n =>
  MonadIO m =>
  MonadIO n =>
  MonadFail m =>
  ReportError e =>
  RpcHandler e env n =>
  MonadBaseControl IO m =>
  Plugin env ->
  TestT n a ->
  TestT m a
integrationSpecDef =
  integrationSpec def