reflex-ghci-0.2.0.0: src/Reflex/Process/GHCi.hs
{-|
- Module: Reflex.Process.GHCi
- Description: Run GHCi processes in a reflex application
-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE MultiWayIf #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecursiveDo #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE StandaloneDeriving #-}
module Reflex.Process.GHCi
( ghci
, ghciWatch
, module X
, hasErrors
) where
import Reflex
import Reflex.FSNotify (watchDirectoryTree)
import Reflex.Process (Process(..))
import Control.Monad
import Control.Monad.Fix (MonadFix)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.ByteString (ByteString)
import qualified Data.ByteString.Char8 as C8
import qualified Data.Map as Map
import System.Directory (getCurrentDirectory)
import qualified System.FSNotify as FS
import System.FilePath.Posix (takeExtension)
import qualified System.Info as Sys
import qualified System.Posix.Signals as Signals
import qualified System.Process as P
import qualified Text.Regex.TDFA as Regex ((=~))
import Reflex.Process.Repl as X
-- | Runs a GHCi process and reloads it whenever the provided event fires
ghci
:: ( Adjustable t m
, MonadFix m
, MonadHold t m
, MonadIO (Performable m)
, MonadIO m
, NotReady t m
, PerformEvent t m
, PostBuild t m
, TriggerEvent t m
)
=> P.CreateProcess -- ^ How to run GHCi
-> Event t [Command] -- ^ Send an expression to evaluate
-> Event t () -- ^ Reload
-> Event t () -- ^ Shutdown
-> m (Repl t)
ghci runGhci expr reload shutdown = do
_ <- liftIO $ Signals.installHandler Signals.sigINT (Signals.Catch (return ())) Nothing
pb <- getPostBuild
rec
r@(Repl proc _finished started exit) <- repl runGhci inputs isPrompt
let inputs = mergeWith (<>)
[ commands setupCommands <$ pb
, command ":r" <$ interrupted
, command ":r" <$ reload'
, command ":q" <$ shutdown
, expr
]
let interruptible = ffor started $ \case
(_, Nothing) -> False
_ -> True
-- Don't allow reloads too close together. Sometimes a single change that
-- results in a reload might actually cause multiple reload events (e.g.,
-- an editor writing a file by deleting and writing it, resulting in two
-- filesystem events)
reloadThrottled <- throttle 0.1 reload
let reload' = gate (not <$> current interruptible) reloadThrottled
interrupted <- performEvent $ ffor (gate (current interruptible) reloadThrottled) $ \_ -> do
liftIO $ P.interruptProcessGroupOf $ _process_handle proc
-- If we can't shut down cleanly within 2 seconds, kill it
forceShutdown <- delay 2 shutdown
performEvent_ $ ffor forceShutdown $ \_ -> liftIO $ do
let h = _process_handle proc
P.interruptProcessGroupOf h
P.terminateProcess h
-- Reinstall the default signal handler after the repl process exits
performEvent_ $ ffor exit $ \_ -> liftIO $
void $ Signals.installHandler Signals.sigINT Signals.Default Nothing
pure r
where
promptPostfix :: ByteString
promptPostfix = "_reflex_ghci_prompt>"
isPrompt cur line = (C8.pack (show cur) <> promptPostfix) == line
setupCommands =
[ ":set prompt-function \\_ x -> let s = \"\\n\" <> show x <> \"" <> promptPostfix <> "\\n\" in System.IO.hPutStr System.IO.stderr s >> pure s"
, ":set -fno-break-on-exception"
, ":set -fno-break-on-error"
, ":set -v1"
, ":set -fno-hide-source-paths"
, ":set -ferror-spans"
, ":set -fdiagnostics-color=never" -- TODO handle ansi escape codes in output
, ":r" -- This is here because we might hit errors at load time, before we've had a chance to set up the prompt. This will re-print those errors.
]
-- | Detect errors reported in stdout or stderr
hasErrors :: Cmd -> Bool
hasErrors (Cmd _ o e) =
let errs l =
l Regex.=~ exceptionMessage ||
l Regex.=~ interactiveErrorMessage ||
l Regex.=~ moduleLoadError
errOnStderr = any errs $ _lines_terminated e
errOnStdout = any (Regex.=~ failedModulesLoaded) $ _lines_terminated o
in errOnStderr || errOnStdout
-- | Run a GHCi process that watches for changes to Haskell source files in the
-- current directory and reloads if they are modified
ghciWatch
:: ( TriggerEvent t m
, PerformEvent t m
, MonadIO (Performable m)
, PostBuild t m
, MonadIO m
, MonadFix m
, MonadHold t m
, Adjustable t m
, NotReady t m
)
=> P.CreateProcess
-> Maybe Command
-> Event t ()
-> Event t ()
-> m (Repl t)
ghciWatch p mexpr reload shutdown = do
-- Get the current directory so we can observe changes in it
dir <- liftIO getCurrentDirectory
-- TODO: Separate the filesystem event logic into its own function
-- Watch the project directory for changes
pb <- getPostBuild
-- TODO Handle changes to "src" and ".cabal" differently. ":r" is only really appropriate
-- when there are changes to loaded modules.
-- We could use ":show modules" to see which hs files are loaded and determine what to do based
-- on that, but we'll need to parse that output.
-- On macOS, use the polling backend due to https://github.com/luite/hfsevents/issues/13
-- TODO check if this is an issue with nixpkgs
let fsConfig = FS.defaultConfig
{ FS.confWatchMode =
if Sys.os == "darwin"
then FS.WatchModePoll 200000
else FS.WatchModeOS
}
fsEvents <- watchDirectoryTree fsConfig (dir <$ pb) $ \e ->
takeExtension (FS.eventPath e) `elem` [".hs", ".lhs"]
-- Events are batched because otherwise we'd get several updates corresponding to one
-- user-level change. For example, saving a file in vim results in an event claiming
-- the file was removed followed almost immediately by an event adding the file
batchedFsEvents <- batchOccurrences 0.05 fsEvents
-- Call GHCi, request a reload every time the files we're watching change.
let reloadEvents = ((() <$ batchedFsEvents) <> reload)
rec g <- ghci p sendExpr reloadEvents shutdown
sendExpr <- delay 0.1 $ fforMaybe (_repl_finished g) $ \finished -> case reverse (Map.elems finished) of
c@(Cmd cmd _ _):_ -> if displayCommand cmd == ":r" && not (hasErrors c)
then case mexpr of
Nothing -> Nothing
Just expr -> Just [expr]
else Nothing
_ -> Nothing
pure g
failedModulesLoaded :: ByteString
failedModulesLoaded = "Failed,.*module.*loaded." :: ByteString
-- TODO: Is there a way to distinguish GHCi's actual exception output
-- from someone printing "*** Exception:" to stderr?
-- TODO: Are there other exception patterns to watch out for?
exceptionMessage :: ByteString
exceptionMessage = "\\*\\*\\* Exception:.*" :: ByteString
interactiveErrorMessage :: ByteString
interactiveErrorMessage = "<interactive>:.*:.*:.error:.*" :: ByteString
moduleLoadError :: ByteString
moduleLoadError = "^.+\\.(l)?hs:[0-9]+:[0-9]+: error:$"