ghcitui-0.4.0.0: lib/ghcitui-core/Ghcitui/Ghcid/Daemon.hs
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE QuasiQuotes #-}
module Ghcitui.Ghcid.Daemon
( -- * The interpreter state
InterpState
( func
, pauseLoc
, moduleFileMap
, breakpoints
, bindings
, logLevel
, logOutput
, execHist
, traceHist
)
, emptyInterpreterState
-- * Startup and shutdown
, startup
, StartupConfig (..)
, quit
-- * Daemon running types
-- | These types are used for restricting concurrent operations and forcing error handling.
, DaemonIO
, DaemonError
-- * Running operations
-- | For running 'DaemonIO' operations.
, schedule
, scheduleWithCb
, threadUnsafeRun
-- * Base operations with the daemon
, exec
, execCleaned
, execMuted
-- * Wrapped operations with the daemon
, step
, stepInto
, load
, continue
-- * Breakpoints
, getBpInCurModule
, getBpInFile
, toggleBreakpointLine
, setBreakpointLine
, deleteBreakpointLine
-- * Tracing
, trace
, history
-- * Tab completion
, tabComplete
-- * Misc
, isExecuting
, readyToExec
, BreakpointArg (..)
, interruptDaemon
, LogOutput (..)
) where
import Control.Concurrent (MVar, forkIO, newEmptyMVar, newMVar, putMVar, takeMVar, isEmptyMVar)
import Control.Error
import Control.Monad (when)
import Control.Monad.IO.Class (MonadIO (..))
import Data.String.Interpolate (i)
import qualified Data.Text as T
import qualified Data.Text.IO as T
import qualified Language.Haskell.Ghcid as Ghcid
import qualified System.IO as IO
import Ghcitui.Ghcid.LogConfig (LogLevel (..), LogOutput (..))
import qualified Ghcitui.Ghcid.ParseContext as ParseContext
import qualified Ghcitui.Ghcid.ParseTabCompletions as ParseTabCompletions
import Ghcitui.Ghcid.StartupConfig (StartupConfig)
import qualified Ghcitui.Ghcid.StartupConfig as StartupConfig
import qualified Ghcitui.Loc as Loc
import qualified Ghcitui.NameBinding as NameBinding
import Ghcitui.Util (showT)
import qualified Ghcitui.Util as Util
type GhciHandle = Ghcid.Ghci
data InterpState a = InterpState
{ _ghci :: !GhciHandle
-- ^ GHCiD handle.
, _ghciLock :: !(MVar ())
-- ^ Lock for single-threaded GHCi operations.
, func :: !(Maybe T.Text)
-- ^ Current pause position function name.
, pauseLoc :: !(Maybe Loc.FileLoc)
-- ^ Current pause position.
, moduleFileMap :: !Loc.ModuleFileMap
-- ^ Mapping between modules and their filepaths.
, stack :: ![T.Text]
-- ^ Program stack (only available during tracing).
, breakpoints :: ![(Int, Loc.ModuleLoc)]
-- ^ Currently set breakpoint locations.
, bindings :: !(Either DaemonError [NameBinding.NameBinding T.Text])
-- ^ Current context value bindings.
, status :: !(Either T.Text a)
-- ^ IDK? I had an idea here at one point.
, logLevel :: !LogLevel
-- ^ How much should we log?
, logOutput :: !LogOutput
-- ^ Where should we log to?
, execHist :: ![T.Text]
-- ^ What's the execution history? Note: different from trace history.
, traceHist :: ![T.Text]
-- ^ Trace history.
}
instance Show (InterpState a) where
show s =
let func' = show s.func
msg = case s.pauseLoc of
Just (Loc.FileLoc filepath' Loc.SourceRange{..}) ->
let srcRngFmt :: String
srcRngFmt =
[i|{sourceRange=(#{startLine},#{startCol})-(#{endLine},#{endCol})}|]
in [i|{func=#{func'}, filepath=#{filepath'}, #{srcRngFmt}}|]
Nothing -> "<unknown pause location>" :: String
in msg
{- | Create an empty/starting interpreter state.
Usually you don't want to call this directly. Instead use 'startup'.
-}
emptyInterpreterState :: (Monoid a) => GhciHandle -> StartupConfig -> IO (InterpState a)
emptyInterpreterState ghci startupConfig = do
ghciLock <- newMVar ()
pure $
InterpState
{ _ghci = ghci
, _ghciLock = ghciLock
, func = Nothing
, pauseLoc = Nothing
, moduleFileMap = mempty
, stack = mempty
, breakpoints = mempty
, bindings = Right mempty
, status = Right mempty
, logLevel = StartupConfig.logLevel startupConfig
, logOutput = StartupConfig.logOutput startupConfig
, execHist = mempty
, traceHist = mempty
}
-- | Reset anything context-based in a 'InterpState'.
contextReset :: (Monoid a) => InterpState a -> InterpState a
contextReset state =
state
{ func = Nothing
, pauseLoc = Nothing
, stack = mempty
, bindings = Right mempty
, status = Right mempty
, traceHist = mempty
}
-- | Append a string to the interpreter's history.
appendExecHist :: T.Text -> InterpState a -> InterpState a
appendExecHist cmd s@InterpState{execHist} = s{execHist = cmd : execHist}
{- | Is the daemon currently in the middle of an expression evaluation, but paused?
Note, this does not indicate whether there's a scheduled 'DaemonIO' operation,
but rather just indicates whether we have stopped at a breakpoint in the middle
of evaluation.
Use 'readyToExec' if you want to query the state of the actual underlying handle.
-}
isExecuting :: InterpState a -> Bool
isExecuting InterpState{func = Nothing} = False
isExecuting InterpState{func = Just _} = True
-- | Is the GHCi lock busy?
readyToExec :: InterpState a -> IO Bool
readyToExec s = isEmptyMVar (_ghciLock s)
-- | Start up the GHCi Daemon.
startup
:: String
-- ^ Command to run (e.g. "ghci" or "cabal repl")
-> FilePath
-- ^ Working directory to run the start up command in.
-> StartupConfig
-- ^ Where do we put the logging?
-> IO (Either DaemonError (InterpState ()))
-- ^ The newly created interpreter handle.
startup cmd wd logOutput = do
-- We don't want any highlighting or colours.
let realCmd = "env TERM='dumb' " <> cmd
state <- liftIO $ do
(ghci, _) <- Ghcid.startGhci realCmd (Just wd) startupStreamCallback
emptyInterpreterState ghci logOutput
logDebug "|startup| GHCi Daemon initted" state
_ <- takeMVar (_ghciLock state)
updatedState <- threadUnsafeRun $ updateState state
_ <- putMVar (_ghciLock state) ()
pure updatedState
startupStreamCallback :: Ghcid.Stream -> String -> IO ()
startupStreamCallback stream msg = do
IO.hPutStrLn handle [i|[ghcid startup:#{prefix}] #{msg}|]
IO.hFlush handle
where
(handle, prefix) = case stream of
Ghcid.Stdout -> (IO.stdout, "out" :: String)
Ghcid.Stderr -> (IO.stderr, "err" :: String)
-- | Shut down the GHCi Daemon.
quit :: InterpState a -> IO (InterpState a)
quit state = do
Ghcid.quit state._ghci
pure state
-- | Update the interpreter state. Wrapper around other updaters.
updateState :: (Monoid a) => InterpState a -> DaemonIO (InterpState a)
updateState state =
updateContext state
>>= updateBindingsWithErrorHandling
>>= updateModuleFileMap
>>= updateBreakList
>>= updateTraceHistory
where
-- Make a wrapper so we don't fail on updating bindings.
-- Parsing bindings turns out to be actually impossible to solve
-- with the current ':show bindings' output, so try our best
-- and keep going.
updateBindingsWithErrorHandling s = updateBindings s `catchE` catchBindings s
catchBindings s er = pure s{bindings = Left er}
-- | Update the current interpreter context.
updateContext :: (Monoid a) => InterpState a -> DaemonIO (InterpState a)
updateContext state@InterpState{_ghci} = do
logDebug "|updateContext| CMD: :show context\n" state
msgs <- liftIO $ Ghcid.exec _ghci ":show context"
let feedback = ParseContext.cleanResponse (T.pack <$> msgs)
logDebug
( "|updateContext| OUT:\n"
<> Util.linesToText msgs
<> "\n"
)
state
if T.null feedback
then pure $ contextReset state -- We exited everything.
else do
let ctx = ParseContext.parseContext feedback
case ctx of
ParseContext.PCError er -> do
let msg = [i|Failed to update context: #{er}|]
logError ("|updateContext| " <> msg) state
throwE $ UpdateContextError msg
ParseContext.PCNoContext -> pure $ contextReset state
ParseContext.PCContext
ParseContext.ParseContextOut{func, filepath, pcSourceRange} ->
pure
state
{ func = Just func
, pauseLoc = Just $ Loc.FileLoc filepath pcSourceRange
}
-- | Update the current local bindings.
updateBindings :: InterpState a -> DaemonIO (InterpState a)
updateBindings state@InterpState{_ghci} = do
logDebug "|updateBindings| CMD: :show bindings\n" state
msgs <- liftIO (Ghcid.exec _ghci ":show bindings")
let feedback = ParseContext.cleanResponse (T.pack <$> msgs)
logDebug
( "|updateBindings| OUT:\n"
<> Util.linesToText msgs
<> "\n"
)
state
case ParseContext.parseBindings feedback of
Right bindings -> pure (state{bindings = pure bindings})
Left er -> do
logError ("|updateBingings| " <> msg) state
throwE $ UpdateBindingError msg
where
msg = [i|Failed to update bindings: #{er}|]
-- | Update the source map given any app state changes.
updateModuleFileMap :: InterpState a -> DaemonIO (InterpState a)
updateModuleFileMap state@InterpState{_ghci, moduleFileMap} = do
logDebug "updateModuleFileMap|: CMD: :show modules\n" state
msgs <- liftIO $ Ghcid.exec _ghci ":show modules"
let packedMsgs = Util.linesToText msgs
logDebug [i||updateModuleFileMap|: OUT: #{packedMsgs}\n|] state
modules <- case ParseContext.parseShowModules packedMsgs of
Right modules -> pure modules
Left er -> throwE (GenericError (showT er))
logDebug [i||updateModuleFileMap| modules: #{modules}|] state
let addedModuleMap = Loc.moduleFileMapFromList modules
let newModuleFileMap = addedModuleMap <> moduleFileMap
pure $ state{moduleFileMap = newModuleFileMap}
updateTraceHistory :: InterpState a -> DaemonIO (InterpState a)
updateTraceHistory state = do
(newState, eTraceHist) <- history state
pure $ case eTraceHist of
Left _ -> newState{traceHist = []}
Right traceHist -> newState{traceHist}
-- | Analogue to @:step@.
step :: (Monoid a) => InterpState a -> ExceptT DaemonError IO (InterpState a)
step = execMuted ":step"
-- | Analogue to @:step <func>@.
stepInto
:: (Monoid a)
=> T.Text
-> InterpState a
-- ^ Function name to jump to.
-> ExceptT DaemonError IO (InterpState a)
-- ^ New interpreter state.
stepInto func = execMuted (":step " <> func)
{- | Analogue to @:history@.
Returns either a 'Left' error message, or a 'Right' list of trace breakpoints.
-}
history :: InterpState a -> DaemonIO (InterpState a, Either T.Text [T.Text])
history state = do
msgStrs <- liftIO $ Ghcid.exec (_ghci state) ":history"
let msgs = T.lines (ParseContext.cleanResponse (T.pack <$> msgStrs))
logDebug [i||history| OUT:\n#{T.unlines msgs}|] state
case msgs of
[] -> throwE (GenericError "':history' unexpectedly returned nothing.")
[oneLine] ->
if ParseContext.isHistoryFailureMsg oneLine
then -- This is probably an error message. Set it as such.
pure (state, Left oneLine)
else -- This is a real trace entry... maybe.
pure (state, Right [oneLine])
_ -> pure (state, Right msgs)
-- | Analogue to @:continue@. Throws out any messages.
continue :: (Monoid a) => InterpState a -> DaemonIO (InterpState a)
continue = execMuted ":continue"
-- | Analogue to @:trace@, with no arguments. Throws out any messages.
trace :: (Monoid a) => InterpState a -> DaemonIO (InterpState a)
trace = execMuted ":trace"
-- | Analogue to @:load <filepath>@. Throws out any messages.
load :: (Monoid a) => FilePath -> InterpState a -> DaemonIO (InterpState a)
load filepath = execMuted (T.pack $ ":load " <> filepath)
{- | Return tab completions for a given prefix.
Analogue to @:complete repl "\<prefix\>"@
See https://downloads.haskell.org/ghc/latest/docs/users_guide/ghci.html#ghci-cmd-:complete
-}
tabComplete
:: (Monoid a)
=> T.Text
-- ^ Text (prefix) to return autocompletions of. Does not need to be escaped.
-> InterpState a
-- ^ Interpreter state to use.
-> DaemonIO (InterpState a, (T.Text, [T.Text]))
-- ^ Resulting state, the prefix, and autocompletions.
tabComplete providedPrefix state = do
-- Tab completion expects input to be 'show'n in quotes.
-- There's probably a better way of doing this!
let escapedPrefix = Util.showT providedPrefix
let cmd = ":complete repl " <> escapedPrefix
(newState, outputLines) <- execCleaned cmd state
(prefix, completions) <- case ParseTabCompletions.parseCompletionsWithHeader outputLines of
Right c -> pure c
Left (ParseTabCompletions.ParseError er) -> throwE (GenericError er)
pure (newState, (prefix, completions))
-- -------------------------------------------------------------------------------------------------
{- | Execute an arbitrary command, as if it was directly written in GHCi.
It is unlikely you want to call this directly, and instead want to call
one of the wrapped functions or 'execMuted' or 'execCleaned'.
-}
exec :: (Monoid a) => T.Text -> InterpState a -> ExceptT DaemonError IO (InterpState a, [T.Text])
exec cmd state@InterpState{_ghci} = do
logDebug ("|exec| CMD: " <> cmd) state
msgs <- liftIO $ Ghcid.exec _ghci (T.unpack cmd)
logDebug [i||exec| OUT:\n#{Util.linesToText msgs}\n|] state
newState <-
updateState
( -- Only append the command to the history if it has something interesting.
if T.null cmd
then state
else appendExecHist cmd state
)
pure (newState, fmap T.pack msgs)
-- | 'exec', but throw out any messages.
execMuted :: (Monoid a) => T.Text -> InterpState a -> ExceptT DaemonError IO (InterpState a)
execMuted cmd state = fst <$> exec cmd state
-- | 'exec', but fully clean the message from prompt.
execCleaned
:: (Monoid a)
=> T.Text
-> InterpState a
-> ExceptT DaemonError IO (InterpState a, [T.Text])
execCleaned cmd state = do
res <- cleaner <$> exec cmd state
logDebug ("|cleaned|:\n" <> (T.unlines . snd $ res)) state
pure res
where
cleaner (s, ls) = (s, T.lines (ParseContext.cleanResponse ls))
-- ------------------------------------------------------------------------------------------------
-- Breakpoint handling
-- ------------------------------------------------------------------------------------------------
-- | Location info passed to breakpoint functions.
data BreakpointArg
= -- | Location in the current file.
LocalLine !Int
| -- | Location in a module.
ModLoc !Loc.ModuleLoc
deriving (Show, Eq, Ord)
-- | Toggle a breakpoint (disable/enable) at a given location.
toggleBreakpointLine :: (Monoid a) => BreakpointArg -> InterpState a -> DaemonIO (InterpState a)
toggleBreakpointLine loc state
| Right True <- isSet = deleteBreakpointLine loc state
| Left x <- isSet = throwE x
| otherwise = setBreakpointLine loc state
where
handleModLoc ml =
fileLoc >>= \fl -> case (Loc.filepath fl, Loc.startLine (Loc.sourceRange fl)) of
(filepath, Just lineno) ->
Right $ lineno `elem` getBpInFile filepath state
(_, _) -> invalidLoc ml
where
fileLoc = maybe (invalidLoc ml) Right (Loc.toFileLoc (moduleFileMap state) ml)
isSet =
case loc of
LocalLine lineno -> Right $ lineno `elem` getBpInCurModule state
ModLoc ml -> handleModLoc ml
invalidLoc :: Loc.ModuleLoc -> Either DaemonError a
invalidLoc ml =
Left $
BreakpointError [i|Cannot locate breakpoint position '#{ml}' in module without source|]
-- | Set a breakpoint at a given line.
setBreakpointLine :: (Monoid a) => BreakpointArg -> InterpState a -> DaemonIO (InterpState a)
setBreakpointLine loc state = do
command <- getCommand
execMuted command state
where
getCommand :: DaemonIO T.Text
getCommand = do
breakPos <- case loc of
LocalLine pos -> pure (showT pos)
ModLoc (Loc.ModuleLoc mod' Loc.SourceRange{startLine, startCol}) ->
let line = maybe "" showT startLine
colno = maybe "" showT startCol
in if line == ""
then
throwE
(BreakpointError "Cannot set breakpoint at unknown line number")
else pure [i|#{mod'} #{line} #{colno}|]
pure (":break " <> breakPos)
-- | Delete a breakpoint at a given line.
deleteBreakpointLine :: (Monoid a) => BreakpointArg -> InterpState a -> DaemonIO (InterpState a)
deleteBreakpointLine loc state =
let convert (LocalLine ll) =
-- TODO: We really should not consider LocalLines valid for this, because we don't
-- really know whether it's local to the paused file, or local to the file
-- we're viewing.
-- But that's a problem for future me.
let fakeSourceRange = Loc.srFromLineNo ll
in do
pauseLoc <- state.pauseLoc
Loc.toModuleLoc
state.moduleFileMap
(pauseLoc{Loc.fSourceRange = fakeSourceRange})
convert (ModLoc ml) = Just ml
-- Get the breakpoint index if it exists.
idxMaybe =
convert loc >>= \ml ->
let match x y =
let srX = Loc.sourceRange x
srY = Loc.sourceRange y
in Loc.startLine srX == Loc.startLine srY
&& Loc.endLine srY == Loc.endLine srY
in headMay
[ idx
| (idx, otherML) <- state.breakpoints
, match ml otherML
]
in case idxMaybe of
Just num -> execMuted (":delete " <> showT num) state
Nothing -> do
logDebug
( [i|No breakpoint at '#{show loc}'; |]
<> [i|breakpoints are found at #{show (breakpoints state)}|]
)
state
pure state
updateBreakList :: InterpState a -> ExceptT DaemonError IO (InterpState a)
updateBreakList state@InterpState{_ghci} = do
logDebug "|updateBreakList| CMD: :show breaks\n" state
msgs <- liftIO (Ghcid.exec _ghci ":show breaks")
logDebug
( "|updateBreakList| OUT:\n"
<> Util.linesToText msgs
)
state
let response = ParseContext.cleanResponse (T.pack <$> msgs)
case ParseContext.parseShowBreaks response of
Right breakpoints -> pure state{breakpoints}
Left er -> throwE (UpdateBreakListError [i|parsing breakpoint list: #{er}|])
-- | Return a list of breakpoint line numbers in the currently paused file.
getBpInCurModule :: InterpState a -> [Int]
getBpInCurModule InterpState{pauseLoc = Nothing} = []
getBpInCurModule s@InterpState{pauseLoc = Just Loc.FileLoc{filepath = fp}} = getBpInFile fp s
-- | Return a list of breakpoint line numbers in the given filepath.
getBpInFile :: FilePath -> InterpState a -> [Int]
getBpInFile fp state =
catMaybes
[ Loc.startLine (Loc.sourceRange loc)
| loc <- breakpointlocs
, Loc.filepath loc == fp
]
where
-- Convert between module locations and file locations
convert (_, x) = Loc.toFileLoc (moduleFileMap state) x
breakpointlocs = mapMaybe convert (breakpoints state)
-- ------------------------------------------------------------------------------------------------
-- | Log a message at the Debug level.
logDebug :: (MonadIO m) => T.Text -> InterpState a -> m ()
logDebug msg state =
liftIO $ do
when (logLevel state >= LogLevel 2) $
logHelper output "[DEBUG]: " msg
where
output = logOutput state
-- Log a message at the Error level.
logError :: (MonadIO m) => T.Text -> InterpState a -> m ()
logError msg state =
liftIO $ do
when (logLevel state >= LogLevel 0) $
logHelper output "[ERROR]: " msg
where
output = logOutput state
logHelper
:: (MonadIO m)
=> LogOutput
-- ^ Where to log?
-> T.Text
-- ^ prefix
-> T.Text
-- ^ Message
-> m ()
logHelper outputLoc prefix msg = do
liftIO $ case outputLoc of
LogOutputFile path -> T.appendFile path fmtMsg
LogOutputStdOut -> T.putStrLn fmtMsg
LogOutputStdErr -> T.hPutStrLn IO.stderr fmtMsg
where
fmtMsg = T.unlines [prefix <> line | line <- T.lines msg]
-- ------------------------------------------------------------------------------------------------
-- Misc
data DaemonError
= GenericError !T.Text
| UpdateBindingError !T.Text
| UpdateBreakListError !T.Text
| BreakpointError !T.Text
| UpdateContextError !T.Text
deriving (Eq, Show)
{- | An IO operation that can fail into a DaemonError.
Execute them synchronously in IO through 'threadUnsafeRun'.
Execute them asynchronously in IO through 'schedule'.
Execute them with a callback with 'scheduleWithCb'
-}
type DaemonIO r = ExceptT DaemonError IO r
{- | Convert Daemon operation to an IO operation. NOT THREAD SAFE.
Prefer 'schedule' or 'scheduleWithCb' in a multithreaded context.
-}
threadUnsafeRun :: DaemonIO r -> IO (Either DaemonError r)
threadUnsafeRun = runExceptT
{- | Schedule execution of this 'DaemonIO' operation, with the result being stored in the MVar.
This function is a thread safe way to run 'DaemonIO' operations.
-}
schedule :: InterpState a -> DaemonIO r -> IO (MVar (Either DaemonError r))
schedule state daemonIO = do
opResultVar <- newEmptyMVar
_ <- forkIO $ do
_ <- takeMVar (_ghciLock state)
result <- threadUnsafeRun daemonIO
_ <- putMVar (_ghciLock state) ()
putMVar opResultVar result
pure opResultVar
{- | Schedule execution of this Daemon IO operation, and run a callback with the result.
'scheduleWithCb' is very common throughout the ghcitui source code, as it primarily
allows inter-thread communication through the use of callbacks.
This function is a thread safe way to run 'DaemonIO' operations.
-}
scheduleWithCb :: InterpState a -> DaemonIO r -> (Either DaemonError r -> IO ()) -> IO ()
scheduleWithCb state daemonIO callback = do
_ <- forkIO $ do
_ <- takeMVar (_ghciLock state)
result <- threadUnsafeRun daemonIO
_ <- putMVar (_ghciLock state) ()
callback result
pure ()
-- | Stop the currently executing process in the daemon.
interruptDaemon :: InterpState a -> IO ()
interruptDaemon InterpState{_ghci} = Ghcid.interrupt _ghci