packages feed

tricorder 0.5.0.0 → 0.6.0.0

raw patch · 105 files changed

+6184/−3838 lines, 105 filesdep ~atelier-coredep ~tricorder-typesPVP ok

version bump matches the API change (PVP)

Dependency ranges changed: atelier-core, tricorder-types

API changes (from Hackage documentation)

- Tricorder.CLI.Arguments: Concise :: Verbosity
- Tricorder.CLI.Arguments: Daemon :: DaemonCommand -> Command
- Tricorder.CLI.Arguments: EvalComments :: EvalCommentsOptions -> Command
- Tricorder.CLI.Arguments: EvalCommentsOptions :: WaitMode -> OutputFormat -> EvalCommentsOptions
- Tricorder.CLI.Arguments: Log :: LogMode -> Command
- Tricorder.CLI.Arguments: Restart :: Force -> Command
- Tricorder.CLI.Arguments: ShowCurrent :: WaitMode
- Tricorder.CLI.Arguments: ShowLog :: LogMode
- Tricorder.CLI.Arguments: ShowLogPath :: LogMode
- Tricorder.CLI.Arguments: Source :: [SourceQuery] -> Command
- Tricorder.CLI.Arguments: Start :: Command
- Tricorder.CLI.Arguments: Status :: StatusOptions -> Command
- Tricorder.CLI.Arguments: StatusOptions :: WaitMode -> OutputFormat -> Verbosity -> Maybe Int -> StatusOptions
- Tricorder.CLI.Arguments: Stop :: Force -> Command
- Tricorder.CLI.Arguments: Test :: TestOptions -> Command
- Tricorder.CLI.Arguments: TestOptions :: Bool -> WaitMode -> TestOptions
- Tricorder.CLI.Arguments: UI :: Command
- Tricorder.CLI.Arguments: Verbose :: Verbosity
- Tricorder.CLI.Arguments: WaitForBuild :: WaitMode
- Tricorder.CLI.Arguments: [expand] :: StatusOptions -> Maybe Int
- Tricorder.CLI.Arguments: [failedOnly] :: TestOptions -> Bool
- Tricorder.CLI.Arguments: [format] :: EvalCommentsOptions -> OutputFormat
- Tricorder.CLI.Arguments: [verbosity] :: StatusOptions -> Verbosity
- Tricorder.CLI.Arguments: [wait] :: EvalCommentsOptions -> WaitMode
- Tricorder.CLI.Arguments: data Command
- Tricorder.CLI.Arguments: data EvalCommentsOptions
- Tricorder.CLI.Arguments: data LogMode
- Tricorder.CLI.Arguments: data StatusOptions
- Tricorder.CLI.Arguments: data TestOptions
- Tricorder.CLI.Arguments: data Verbosity
- Tricorder.CLI.Arguments: data WaitMode
- Tricorder.CLI.UI.Keys: dispatcher :: IO () -> KeyConfig KeyEvent -> KeyDispatcher KeyEvent (EventM Viewports State)
- Tricorder.CLI.UI.Keys: keybindForRoute :: KeyConfig KeyEvent -> Route -> Maybe Binding
- Tricorder.CLI.UI.Route: Help :: Route
- Tricorder.Config: LoadedConfig :: Value -> LoadedConfig
- Tricorder.Config: configFileName :: FilePath
- Tricorder.Config: inputLoadedConfig :: forall (es :: [Effect]) a. (FileSystem :> es, Reader ProjectRoot :> es) => Eff (Input LoadedConfig ': es) a -> Eff es a
- Tricorder.Config: newtype LoadedConfig
- Tricorder.Config: runLoadedConfig :: forall (es :: [Effect]) a. (FileSystem :> es, Reader ProjectRoot :> es) => Eff (Reader LoadedConfig ': es) a -> Eff es a
- Tricorder.Daemon.GhciSession: Controls :: m LoadResult -> m () -> (FilePath -> m LoadResult) -> (Text -> m LoadResult) -> Controls (m :: Type -> Type)
- Tricorder.Daemon.GhciSession: LoadResult :: Int -> Set FilePath -> Map FilePath LoadedModule -> [Text] -> [Diagnostic] -> LoadResult
- Tricorder.Daemon.GhciSession: LoadedModule :: FilePath -> Text -> LoadedModule
- Tricorder.Daemon.GhciSession: [add] :: Controls (m :: Type -> Type) -> FilePath -> m LoadResult
- Tricorder.Daemon.GhciSession: [compiledFiles] :: LoadResult -> Set FilePath
- Tricorder.Daemon.GhciSession: [diagnostics] :: LoadResult -> [Diagnostic]
- Tricorder.Daemon.GhciSession: [interrupt] :: Controls (m :: Type -> Type) -> m ()
- Tricorder.Daemon.GhciSession: [loadedModules] :: LoadResult -> Map FilePath LoadedModule
- Tricorder.Daemon.GhciSession: [moduleCount] :: LoadResult -> Int
- Tricorder.Daemon.GhciSession: [moduleName] :: LoadedModule -> Text
- Tricorder.Daemon.GhciSession: [relPath] :: LoadedModule -> FilePath
- Tricorder.Daemon.GhciSession: [reload] :: Controls (m :: Type -> Type) -> m LoadResult
- Tricorder.Daemon.GhciSession: [targetNames] :: LoadResult -> [Text]
- Tricorder.Daemon.GhciSession: [unadd] :: Controls (m :: Type -> Type) -> Text -> m LoadResult
- Tricorder.Daemon.GhciSession: data Controls (m :: Type -> Type)
- Tricorder.Daemon.GhciSession: data GhciSession (a :: Type -> Type) b
- Tricorder.Daemon.GhciSession: data LoadResult
- Tricorder.Daemon.GhciSession: data LoadedModule
- Tricorder.Daemon.GhciSession: runGhciSession :: forall (es :: [Effect]) a. (Conc :> es, Concurrent :> es, File :> es, Log :> es, Process :> es, Timeout :> es) => Eff (GhciSession ': es) a -> Eff es a
- Tricorder.Daemon.GhciSession: runGhciSessionScripted :: forall (es :: [(Type -> Type) -> Type -> Type]) a. [Either SomeException LoadResult] -> Eff (GhciSession ': es) a -> Eff es a
- Tricorder.Daemon.GhciSession: transformControls :: (forall a. () => m a -> n a) -> Controls m -> Controls n
- Tricorder.Daemon.GhciSession: withGhci :: forall (es :: [Effect]) a. (GhciSession :> es, Pub BuildProgress :> es) => RenderedCommand 'Build -> ProjectRoot -> (LoadResult -> Controls (Eff es) -> Eff es a) -> Eff es a
- Tricorder.Daemon.GhciSession: withGhciWith :: forall a (es :: [Effect]). (HasCallStack, GhciSession :> es) => (BuildProgress -> Eff es ()) -> RenderedCommand 'Build -> ProjectRoot -> (LoadResult -> Controls (Eff es) -> Eff es a) -> Eff es a
- Tricorder.Daemon.GhciSession.GhciParser: GError :: GhciSeverity
- Tricorder.Daemon.GhciSession.GhciParser: GLoadConfig :: FilePath -> GhciLoad
- Tricorder.Daemon.GhciSession.GhciParser: GLoading :: GhciLoading -> GhciLoad
- Tricorder.Daemon.GhciSession.GhciParser: GMessage :: GhciMessage -> GhciLoad
- Tricorder.Daemon.GhciSession.GhciParser: GSummary :: LoadOutcome -> GhciLoad
- Tricorder.Daemon.GhciSession.GhciParser: GWarning :: GhciSeverity
- Tricorder.Daemon.GhciSession.GhciParser: GhciLoading :: Int -> Int -> Text -> FilePath -> GhciLoading
- Tricorder.Daemon.GhciSession.GhciParser: GhciMessage :: GhciSeverity -> FilePath -> Position -> Position -> [Text] -> GhciMessage
- Tricorder.Daemon.GhciSession.GhciParser: LoadFailed :: LoadOutcome
- Tricorder.Daemon.GhciSession.GhciParser: LoadResult :: Int -> Set FilePath -> Map FilePath LoadedModule -> [Text] -> [Diagnostic] -> LoadResult
- Tricorder.Daemon.GhciSession.GhciParser: LoadSucceeded :: LoadOutcome
- Tricorder.Daemon.GhciSession.GhciParser: LoadedModule :: FilePath -> Text -> LoadedModule
- Tricorder.Daemon.GhciSession.GhciParser: Position :: Int -> Int -> Position
- Tricorder.Daemon.GhciSession.GhciParser: [col] :: Position -> Int
- Tricorder.Daemon.GhciSession.GhciParser: [compiledFiles] :: LoadResult -> Set FilePath
- Tricorder.Daemon.GhciSession.GhciParser: [diagnostics] :: LoadResult -> [Diagnostic]
- Tricorder.Daemon.GhciSession.GhciParser: [endPos] :: GhciMessage -> Position
- Tricorder.Daemon.GhciSession.GhciParser: [file] :: GhciMessage -> FilePath
- Tricorder.Daemon.GhciSession.GhciParser: [index] :: GhciLoading -> Int
- Tricorder.Daemon.GhciSession.GhciParser: [line] :: Position -> Int
- Tricorder.Daemon.GhciSession.GhciParser: [loadedModules] :: LoadResult -> Map FilePath LoadedModule
- Tricorder.Daemon.GhciSession.GhciParser: [messageLines] :: GhciMessage -> [Text]
- Tricorder.Daemon.GhciSession.GhciParser: [moduleCount] :: LoadResult -> Int
- Tricorder.Daemon.GhciSession.GhciParser: [moduleName] :: LoadedModule -> Text
- Tricorder.Daemon.GhciSession.GhciParser: [relPath] :: LoadedModule -> FilePath
- Tricorder.Daemon.GhciSession.GhciParser: [severity] :: GhciMessage -> GhciSeverity
- Tricorder.Daemon.GhciSession.GhciParser: [sourceFile] :: GhciLoading -> FilePath
- Tricorder.Daemon.GhciSession.GhciParser: [startPos] :: GhciMessage -> Position
- Tricorder.Daemon.GhciSession.GhciParser: [targetNames] :: LoadResult -> [Text]
- Tricorder.Daemon.GhciSession.GhciParser: [total] :: GhciLoading -> Int
- Tricorder.Daemon.GhciSession.GhciParser: collectResult :: FilePath -> [Text] -> [(Text, FilePath)] -> [Text] -> LoadResult
- Tricorder.Daemon.GhciSession.GhciParser: collectResultCustom :: FilePath -> [GhciLoad] -> [(Text, FilePath)] -> [Text] -> LoadResult
- Tricorder.Daemon.GhciSession.GhciParser: data GhciLoad
- Tricorder.Daemon.GhciSession.GhciParser: data GhciLoading
- Tricorder.Daemon.GhciSession.GhciParser: data GhciMessage
- Tricorder.Daemon.GhciSession.GhciParser: data GhciSeverity
- Tricorder.Daemon.GhciSession.GhciParser: data LoadOutcome
- Tricorder.Daemon.GhciSession.GhciParser: data LoadResult
- Tricorder.Daemon.GhciSession.GhciParser: data LoadedModule
- Tricorder.Daemon.GhciSession.GhciParser: data Position
- Tricorder.Daemon.GhciSession.GhciParser: extractTitle :: [String] -> Text
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Classes.Eq Tricorder.Daemon.GhciSession.GhciParser.GhciLoad
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Classes.Eq Tricorder.Daemon.GhciSession.GhciParser.GhciLoading
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Classes.Eq Tricorder.Daemon.GhciSession.GhciParser.GhciMessage
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Classes.Eq Tricorder.Daemon.GhciSession.GhciParser.GhciSeverity
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Classes.Eq Tricorder.Daemon.GhciSession.GhciParser.LoadOutcome
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Classes.Eq Tricorder.Daemon.GhciSession.GhciParser.LoadResult
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Classes.Eq Tricorder.Daemon.GhciSession.GhciParser.LoadedModule
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Classes.Eq Tricorder.Daemon.GhciSession.GhciParser.Position
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Classes.Ord Tricorder.Daemon.GhciSession.GhciParser.GhciSeverity
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Show.Show Tricorder.Daemon.GhciSession.GhciParser.GhciLoad
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Show.Show Tricorder.Daemon.GhciSession.GhciParser.GhciLoading
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Show.Show Tricorder.Daemon.GhciSession.GhciParser.GhciMessage
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Show.Show Tricorder.Daemon.GhciSession.GhciParser.GhciSeverity
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Show.Show Tricorder.Daemon.GhciSession.GhciParser.LoadOutcome
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Show.Show Tricorder.Daemon.GhciSession.GhciParser.LoadResult
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Show.Show Tricorder.Daemon.GhciSession.GhciParser.LoadedModule
- Tricorder.Daemon.GhciSession.GhciParser: instance GHC.Show.Show Tricorder.Daemon.GhciSession.GhciParser.Position
- Tricorder.Daemon.GhciSession.GhciParser: instance Text.Megaparsec.Stream.Stream Tricorder.Daemon.GhciSession.GhciParser.LineStream
- Tricorder.Daemon.GhciSession.GhciParser: isLocationLess :: FilePath -> Bool
- Tricorder.Daemon.GhciSession.GhciParser: parseProgressLine :: Text -> Maybe GhciLoading
- Tricorder.Daemon.GhciSession.GhciParser: parseReload :: [Text] -> [GhciLoad]
- Tricorder.Daemon.GhciSession.GhciParser: parseShowModules :: [Text] -> [(Text, FilePath)]
- Tricorder.Daemon.GhciSession.GhciParser: parseShowTargets :: [Text] -> [Text]
- Tricorder.Daemon.GhciSession.GhciParser: pathSuffixesAsModuleName :: FilePath -> [Text]
- Tricorder.Daemon.GhciSession.GhciParser: reloadFailed :: [GhciLoad] -> Bool
- Tricorder.Daemon.GhciSession.GhciParser: resolveKnownTargets :: Map FilePath LoadedModule -> LoadResult -> Map FilePath LoadedModule
- Tricorder.Daemon.GhciSession.GhciParser: stripAnsi :: Text -> Text
- Tricorder.Daemon.GhciSession.GhciParser: toAbsolute :: FilePath -> FilePath -> FilePath
- Tricorder.Daemon.GhciSession.GhciParser: toRelative :: FilePath -> FilePath -> FilePath
- Tricorder.Daemon.GhciSession.GhciParser: unattributedFailure :: Diagnostic
- Tricorder.Daemon.GhciSession.GhciProcess: Busy :: Int -> SessionState
- Tricorder.Daemon.GhciSession.GhciProcess: Config :: Second -> Second -> [Text] -> Config
- Tricorder.Daemon.GhciSession.GhciProcess: GhciProcess :: Handle -> Handle -> Handle -> RunningProcess Handle Handle Handle -> TVar SessionState -> GhciProcess
- Tricorder.Daemon.GhciSession.GhciProcess: Idle :: Int -> SessionState
- Tricorder.Daemon.GhciSession.GhciProcess: MkUnexpectedExit :: Text -> Text -> UnexpectedExit
- Tricorder.Daemon.GhciSession.GhciProcess: NoOpIdle :: InterruptDecision
- Tricorder.Daemon.GhciSession.GhciProcess: SendInterruptFor :: Int -> InterruptDecision
- Tricorder.Daemon.GhciSession.GhciProcess: StartupFailed :: Text -> GhciProcessError
- Tricorder.Daemon.GhciSession.GhciProcess: StartupTimeout :: GhciProcessError
- Tricorder.Daemon.GhciSession.GhciProcess: UnexpectedExit :: UnexpectedExit -> GhciProcessError
- Tricorder.Daemon.GhciSession.GhciProcess: [errorMessage] :: UnexpectedExit -> Text
- Tricorder.Daemon.GhciSession.GhciProcess: [extraSetupCommands] :: Config -> [Text]
- Tricorder.Daemon.GhciSession.GhciProcess: [handle] :: GhciProcess -> RunningProcess Handle Handle Handle
- Tricorder.Daemon.GhciSession.GhciProcess: [marker] :: UnexpectedExit -> Text
- Tricorder.Daemon.GhciSession.GhciProcess: [shutdownTimeout] :: Config -> Second
- Tricorder.Daemon.GhciSession.GhciProcess: [startupTimeout] :: Config -> Second
- Tricorder.Daemon.GhciSession.GhciProcess: [stateVar] :: GhciProcess -> TVar SessionState
- Tricorder.Daemon.GhciSession.GhciProcess: [stderr] :: GhciProcess -> Handle
- Tricorder.Daemon.GhciSession.GhciProcess: [stdin] :: GhciProcess -> Handle
- Tricorder.Daemon.GhciSession.GhciProcess: [stdout] :: GhciProcess -> Handle
- Tricorder.Daemon.GhciSession.GhciProcess: addGhci :: forall (es :: [Effect]). (Conc :> es, Concurrent :> es, File :> es, Log :> es) => GhciProcess -> FilePath -> FilePath -> (GhciLoading -> Eff es ()) -> Eff es LoadResult
- Tricorder.Daemon.GhciSession.GhciProcess: collectGhciResult :: forall (es :: [Effect]). (Conc :> es, Concurrent :> es, File :> es, Log :> es) => GhciProcess -> [Text] -> FilePath -> Eff es LoadResult
- Tricorder.Daemon.GhciSession.GhciProcess: data Config
- Tricorder.Daemon.GhciSession.GhciProcess: data GhciProcess
- Tricorder.Daemon.GhciSession.GhciProcess: data GhciProcessError
- Tricorder.Daemon.GhciSession.GhciProcess: data InterruptDecision
- Tricorder.Daemon.GhciSession.GhciProcess: data SessionState
- Tricorder.Daemon.GhciSession.GhciProcess: data UnexpectedExit
- Tricorder.Daemon.GhciSession.GhciProcess: decideInterrupt :: SessionState -> (SessionState, InterruptDecision)
- Tricorder.Daemon.GhciSession.GhciProcess: drainUntil :: forall (es :: [Effect]). (File :> es, Log :> es) => Handle -> Text -> (Text -> Eff es ()) -> Eff es [Text]
- Tricorder.Daemon.GhciSession.GhciProcess: execGhci :: forall (es :: [Effect]). (Conc :> es, Concurrent :> es, File :> es, Log :> es) => GhciProcess -> Text -> (GhciLoading -> Eff es ()) -> Eff es [Text]
- Tricorder.Daemon.GhciSession.GhciProcess: instance Data.Default.Internal.Default Tricorder.Daemon.GhciSession.GhciProcess.Config
- Tricorder.Daemon.GhciSession.GhciProcess: instance GHC.Classes.Eq Tricorder.Daemon.GhciSession.GhciProcess.GhciProcessError
- Tricorder.Daemon.GhciSession.GhciProcess: instance GHC.Classes.Eq Tricorder.Daemon.GhciSession.GhciProcess.InterruptDecision
- Tricorder.Daemon.GhciSession.GhciProcess: instance GHC.Classes.Eq Tricorder.Daemon.GhciSession.GhciProcess.SessionState
- Tricorder.Daemon.GhciSession.GhciProcess: instance GHC.Classes.Eq Tricorder.Daemon.GhciSession.GhciProcess.UnexpectedExit
- Tricorder.Daemon.GhciSession.GhciProcess: instance GHC.Exception.Type.Exception Tricorder.Daemon.GhciSession.GhciProcess.GhciProcessError
- Tricorder.Daemon.GhciSession.GhciProcess: instance GHC.Show.Show Tricorder.Daemon.GhciSession.GhciProcess.GhciProcessError
- Tricorder.Daemon.GhciSession.GhciProcess: instance GHC.Show.Show Tricorder.Daemon.GhciSession.GhciProcess.InterruptDecision
- Tricorder.Daemon.GhciSession.GhciProcess: instance GHC.Show.Show Tricorder.Daemon.GhciSession.GhciProcess.SessionState
- Tricorder.Daemon.GhciSession.GhciProcess: instance GHC.Show.Show Tricorder.Daemon.GhciSession.GhciProcess.UnexpectedExit
- Tricorder.Daemon.GhciSession.GhciProcess: interruptGhci :: forall (es :: [Effect]). (Concurrent :> es, File :> es, Process :> es) => GhciProcess -> Eff es ()
- Tricorder.Daemon.GhciSession.GhciProcess: reloadGhci :: forall (es :: [Effect]). (Conc :> es, Concurrent :> es, File :> es, Log :> es) => GhciProcess -> FilePath -> (GhciLoading -> Eff es ()) -> Eff es LoadResult
- Tricorder.Daemon.GhciSession.GhciProcess: terminateGhciProcess :: forall (es :: [Effect]). Process :> es => GhciProcess -> Eff es ()
- Tricorder.Daemon.GhciSession.GhciProcess: unaddGhci :: forall (es :: [Effect]). (Conc :> es, Concurrent :> es, File :> es, Log :> es) => GhciProcess -> Text -> FilePath -> (GhciLoading -> Eff es ()) -> Eff es LoadResult
- Tricorder.Daemon.GhciSession.GhciProcess: waitForBannerOrFail :: forall (es :: [Effect]). (Conc :> es, Concurrent :> es, File :> es, Timeout :> es) => Second -> Handle -> Handle -> Eff es ()
- Tricorder.Daemon.GhciSession.GhciProcess: withGhciProcess :: forall (es :: [Effect]) (stage :: Stage) a. (Conc :> es, Concurrent :> es, File :> es, Log :> es, Process :> es, Timeout :> es) => Config -> RenderedCommand stage -> FilePath -> (GhciLoading -> Eff es ()) -> (GhciProcess -> Eff es ()) -> (GhciProcess -> [Text] -> Eff es a) -> Eff es a
- Tricorder.Session: inputSession :: forall (es :: [Effect]) a. (FileSystem :> es, Input LoadedConfig :> es, Input [CabalFile] :> es, Log :> es, Reader ProjectRoot :> es) => Eff (Input Session ': es) a -> Eff es a
- Tricorder.Session: loadSession :: forall (es :: [Effect]). (FileSystem :> es, Input LoadedConfig :> es, Input [CabalFile] :> es, Log :> es, Reader ProjectRoot :> es) => Eff es Session
- Tricorder.Session: show :: Session -> Text
- Tricorder.Session.CommandTemplate: show :: forall (stage :: Stage). CommandTemplate stage -> Text
- Tricorder.Session.Hooks: Hook :: Maybe Text -> Maybe Text -> Hook
- Tricorder.Session.Hooks: data Hook
- Tricorder.Session.Hooks: instance Data.Aeson.Types.FromJSON.FromJSON Tricorder.Session.Hooks.Hook
- Tricorder.Session.Hooks: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.Session.Hooks.Hook
- Tricorder.Session.Hooks: instance Data.Default.Internal.Default Tricorder.Session.Hooks.Hook
- Tricorder.Session.Hooks: instance GHC.Classes.Eq Tricorder.Session.Hooks.Hook
- Tricorder.Session.Hooks: instance GHC.Generics.Generic Tricorder.Session.Hooks.Hook
- Tricorder.Session.Hooks: instance GHC.Show.Show Tricorder.Session.Hooks.Hook
- Tricorder.Session.Hooks: runHook :: forall (es :: [Effect]). (Process :> es, Reader ProjectRoot :> es) => Text -> Eff es ()
- Tricorder.Session.Stage.Build.Session: show :: BuildSession -> Text
- Tricorder.Session.Stage.Eval.Session: show :: EvalSession -> Text
- Tricorder.Session.Stage.Test.Session: show :: TestSession -> Text
- Tricorder.Session.Util: showList :: (a -> Text) -> [a] -> Text
- Tricorder.Socket.UnixSocket: acceptHandle :: forall (es :: [Effect]). (HasCallStack, UnixSocket :> es) => Socket -> Eff es Handle
- Tricorder.Socket.UnixSocket: bindSocket :: forall (es :: [Effect]). (HasCallStack, UnixSocket :> es) => FilePath -> Eff es Socket
- Tricorder.Socket.UnixSocket: closeHandle :: forall (es :: [Effect]). (HasCallStack, UnixSocket :> es) => Handle -> Eff es ()
- Tricorder.Socket.UnixSocket: readLine :: forall (es :: [Effect]). (HasCallStack, UnixSocket :> es) => Handle -> Eff es Text
- Tricorder.Socket.UnixSocket: removeSocketFile :: forall (es :: [Effect]). (HasCallStack, UnixSocket :> es) => FilePath -> Eff es ()
- Tricorder.Socket.UnixSocket: runUnixSocketIO :: forall (es :: [Effect]) a. (File :> es, IOE :> es) => Eff (UnixSocket ': es) a -> Eff es a
- Tricorder.Socket.UnixSocket: runUnixSocketScripted :: forall (es :: [Effect]) a. (File :> es, IOE :> es) => [SocketScript] -> Eff (UnixSocket ': es) a -> Eff es a
- Tricorder.Socket.UnixSocket: sendLine :: forall (es :: [Effect]). (HasCallStack, UnixSocket :> es) => Handle -> Text -> Eff es ()
- Tricorder.Socket.UnixSocket: socketFileExists :: forall (es :: [Effect]). (HasCallStack, UnixSocket :> es) => FilePath -> Eff es Bool
+ Tricorder.CLI.App.CLIResponse: class CLIResponse a
+ Tricorder.CLI.App.CLIResponse: instance Data.Aeson.Types.ToJSON.ToJSON a => Tricorder.CLI.App.CLIResponse.CLIResponse a
+ Tricorder.CLI.App.CLIResponse: renderCLIResponse :: CLIResponse a => a -> LText
+ Tricorder.CLI.App.Output: Fail :: ExitCode -> LText -> Output a
+ Tricorder.CLI.App.Output: JsonOutput :: a -> JsonOutput a
+ Tricorder.CLI.App.Output: Output :: a -> Output a
+ Tricorder.CLI.App.Output: TextOutput :: a -> TextOutput a
+ Tricorder.CLI.App.Output: data JsonOutput a
+ Tricorder.CLI.App.Output: data Output a
+ Tricorder.CLI.App.Output: data TextOutput a
+ Tricorder.CLI.App.Output: instance Data.Aeson.Types.ToJSON.ToJSON a => Tricorder.CLI.App.CLIResponse.CLIResponse (Tricorder.CLI.App.Output.JsonOutput a)
+ Tricorder.CLI.App.Output: instance GHC.Base.Applicative Tricorder.CLI.App.Output.Output
+ Tricorder.CLI.App.Output: instance GHC.Base.Functor Tricorder.CLI.App.Output.Output
+ Tricorder.CLI.App.Output: instance GHC.Base.Monad Tricorder.CLI.App.Output.Output
+ Tricorder.CLI.App.Output: instance Tricorder.CLI.App.CLIResponse.CLIResponse a => Tricorder.CLI.App.CLIResponse.CLIResponse (Tricorder.CLI.App.Output.Output a)
+ Tricorder.CLI.App.Output: instance Tricorder.CLI.App.CLIResponse.CLIResponse a => Tricorder.CLI.App.CLIResponse.CLIResponse (Tricorder.CLI.App.Output.TextOutput a)
+ Tricorder.CLI.App.Test: run :: forall (es :: [Effect]). (Console :> es, Daemons :> es, Delay :> es, Exit :> es, File :> es, IOE :> es, Reader PidFile :> es, Reader SocketPath :> es, UnixSocket :> es) => Command -> Eff es ()
+ Tricorder.CLI.App.Test.Json: argumentError :: Text -> Either Text Text
+ Tricorder.CLI.App.Test.Json: showBasePhase :: Either Text BuildState -> (Suites -> Response a) -> Response a
+ Tricorder.CLI.App.Test.Json: showSuites :: [TestTarget] -> TestFilter -> Schema -> Suites -> Response SuitesResponse
+ Tricorder.CLI.App.Test.Json: wrapResponse :: ToJSON a => Response a -> Either Text Text
+ Tricorder.CLI.App.Test.Json.Responses: Error :: MessageWithLoc -> Response a
+ Tricorder.CLI.App.Test.Json.Responses: MessageWithLoc :: Text -> Text -> MessageWithLoc
+ Tricorder.CLI.App.Test.Json.Responses: Pending :: MessageWithLoc -> Response a
+ Tricorder.CLI.App.Test.Json.Responses: Success :: a -> Response a
+ Tricorder.CLI.App.Test.Json.Responses: SuitesResponse :: [Projection] -> SuitesResponse
+ Tricorder.CLI.App.Test.Json.Responses: [location] :: MessageWithLoc -> Text
+ Tricorder.CLI.App.Test.Json.Responses: [message] :: MessageWithLoc -> Text
+ Tricorder.CLI.App.Test.Json.Responses: [suites] :: SuitesResponse -> [Projection]
+ Tricorder.CLI.App.Test.Json.Responses: data MessageWithLoc
+ Tricorder.CLI.App.Test.Json.Responses: data Response a
+ Tricorder.CLI.App.Test.Json.Responses: instance Data.Aeson.Types.FromJSON.FromJSON Tricorder.CLI.App.Test.Json.Responses.MessageWithLoc
+ Tricorder.CLI.App.Test.Json.Responses: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.CLI.App.Test.Json.Responses.MessageWithLoc
+ Tricorder.CLI.App.Test.Json.Responses: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.CLI.App.Test.Json.Responses.SuitesResponse
+ Tricorder.CLI.App.Test.Json.Responses: instance Data.Aeson.Types.ToJSON.ToJSON a => Data.Aeson.Types.ToJSON.ToJSON (Tricorder.CLI.App.Test.Json.Responses.Response a)
+ Tricorder.CLI.App.Test.Json.Responses: instance GHC.Base.Applicative Tricorder.CLI.App.Test.Json.Responses.Response
+ Tricorder.CLI.App.Test.Json.Responses: instance GHC.Base.Functor Tricorder.CLI.App.Test.Json.Responses.Response
+ Tricorder.CLI.App.Test.Json.Responses: instance GHC.Base.Monad Tricorder.CLI.App.Test.Json.Responses.Response
+ Tricorder.CLI.App.Test.Json.Responses: instance GHC.Classes.Eq Tricorder.CLI.App.Test.Json.Responses.MessageWithLoc
+ Tricorder.CLI.App.Test.Json.Responses: instance GHC.Classes.Eq Tricorder.CLI.App.Test.Json.Responses.SuitesResponse
+ Tricorder.CLI.App.Test.Json.Responses: instance GHC.Classes.Eq a => GHC.Classes.Eq (Tricorder.CLI.App.Test.Json.Responses.Response a)
+ Tricorder.CLI.App.Test.Json.Responses: instance GHC.Generics.Generic (Tricorder.CLI.App.Test.Json.Responses.Response a)
+ Tricorder.CLI.App.Test.Json.Responses: instance GHC.Generics.Generic Tricorder.CLI.App.Test.Json.Responses.MessageWithLoc
+ Tricorder.CLI.App.Test.Json.Responses: instance GHC.Show.Show Tricorder.CLI.App.Test.Json.Responses.MessageWithLoc
+ Tricorder.CLI.App.Test.Json.Responses: instance GHC.Show.Show Tricorder.CLI.App.Test.Json.Responses.SuitesResponse
+ Tricorder.CLI.App.Test.Json.Responses: instance GHC.Show.Show a => GHC.Show.Show (Tricorder.CLI.App.Test.Json.Responses.Response a)
+ Tricorder.CLI.App.Test.Json.Responses: newtype SuitesResponse
+ Tricorder.CLI.App.Test.SuiteView: CaseFailed :: Outcome
+ Tricorder.CLI.App.Test.SuiteView: CasePassed :: Outcome
+ Tricorder.CLI.App.Test.SuiteView: CaseView :: Text -> Outcome -> Maybe Text -> CaseView
+ Tricorder.CLI.App.Test.SuiteView: Errored :: Status
+ Tricorder.CLI.App.Test.SuiteView: Failed :: Status
+ Tricorder.CLI.App.Test.SuiteView: Passed :: Status
+ Tricorder.CLI.App.Test.SuiteView: Running :: Status
+ Tricorder.CLI.App.Test.SuiteView: SuiteView :: Text -> Status -> Maybe Text -> Maybe Int -> Maybe Int -> Maybe Duration -> Maybe Text -> Maybe [CaseView] -> SuiteView
+ Tricorder.CLI.App.Test.SuiteView: [cases] :: SuiteView -> Maybe [CaseView]
+ Tricorder.CLI.App.Test.SuiteView: [description] :: CaseView -> Text
+ Tricorder.CLI.App.Test.SuiteView: [durationMs] :: SuiteView -> Maybe Duration
+ Tricorder.CLI.App.Test.SuiteView: [error] :: SuiteView -> Maybe Text
+ Tricorder.CLI.App.Test.SuiteView: [failure] :: CaseView -> Maybe Text
+ Tricorder.CLI.App.Test.SuiteView: [name] :: SuiteView -> Text
+ Tricorder.CLI.App.Test.SuiteView: [outcome] :: CaseView -> Outcome
+ Tricorder.CLI.App.Test.SuiteView: [output] :: SuiteView -> Maybe Text
+ Tricorder.CLI.App.Test.SuiteView: [passedCases] :: SuiteView -> Maybe Int
+ Tricorder.CLI.App.Test.SuiteView: [status] :: SuiteView -> Status
+ Tricorder.CLI.App.Test.SuiteView: [totalCases] :: SuiteView -> Maybe Int
+ Tricorder.CLI.App.Test.SuiteView: data CaseView
+ Tricorder.CLI.App.Test.SuiteView: data Outcome
+ Tricorder.CLI.App.Test.SuiteView: data Status
+ Tricorder.CLI.App.Test.SuiteView: data SuiteView
+ Tricorder.CLI.App.Test.SuiteView: defaultFields :: FieldMask
+ Tricorder.CLI.App.Test.SuiteView: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.CLI.App.Test.SuiteView.CaseView
+ Tricorder.CLI.App.Test.SuiteView: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.CLI.App.Test.SuiteView.Outcome
+ Tricorder.CLI.App.Test.SuiteView: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.CLI.App.Test.SuiteView.Status
+ Tricorder.CLI.App.Test.SuiteView: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.CLI.App.Test.SuiteView.SuiteView
+ Tricorder.CLI.App.Test.SuiteView: instance GHC.Classes.Eq Tricorder.CLI.App.Test.SuiteView.CaseView
+ Tricorder.CLI.App.Test.SuiteView: instance GHC.Classes.Eq Tricorder.CLI.App.Test.SuiteView.Outcome
+ Tricorder.CLI.App.Test.SuiteView: instance GHC.Classes.Eq Tricorder.CLI.App.Test.SuiteView.Status
+ Tricorder.CLI.App.Test.SuiteView: instance GHC.Classes.Eq Tricorder.CLI.App.Test.SuiteView.SuiteView
+ Tricorder.CLI.App.Test.SuiteView: instance GHC.Generics.Generic Tricorder.CLI.App.Test.SuiteView.CaseView
+ Tricorder.CLI.App.Test.SuiteView: instance GHC.Generics.Generic Tricorder.CLI.App.Test.SuiteView.Outcome
+ Tricorder.CLI.App.Test.SuiteView: instance GHC.Generics.Generic Tricorder.CLI.App.Test.SuiteView.Status
+ Tricorder.CLI.App.Test.SuiteView: instance GHC.Generics.Generic Tricorder.CLI.App.Test.SuiteView.SuiteView
+ Tricorder.CLI.App.Test.SuiteView: instance GHC.Show.Show Tricorder.CLI.App.Test.SuiteView.CaseView
+ Tricorder.CLI.App.Test.SuiteView: instance GHC.Show.Show Tricorder.CLI.App.Test.SuiteView.Outcome
+ Tricorder.CLI.App.Test.SuiteView: instance GHC.Show.Show Tricorder.CLI.App.Test.SuiteView.Status
+ Tricorder.CLI.App.Test.SuiteView: instance GHC.Show.Show Tricorder.CLI.App.Test.SuiteView.SuiteView
+ Tricorder.CLI.App.Test.SuiteView: schema :: Schema
+ Tricorder.CLI.App.Test.SuiteView: selectSuites :: [TestTarget] -> TestFilter -> Suites -> Either (NonEmpty TestTarget) [SuiteView]
+ Tricorder.CLI.App.Test.Text: showBasePhase :: (Suites -> Either Text Text) -> BuildPhase -> Either Text Text
+ Tricorder.CLI.App.Test.Text: showSuites :: [TestTarget] -> TestFilter -> Schema -> Suites -> Either Text Text
+ Tricorder.CLI.App.Test.Text: wrapTextShow :: Either Text BuildState -> (BuildPhase -> Either Text Text) -> Either Text Text
+ Tricorder.CLI.Arguments.IfDaemonIsStopped: parser :: Parser IfDaemonIsStopped
+ Tricorder.CLI.Arguments.Test: parser :: ParserInfo Command
+ Tricorder.CLI.Arguments.TestFilter: parser :: Mod FlagFields TestFilter -> Parser TestFilter
+ Tricorder.CLI.Arguments.WaitMode: parser :: Parser WaitMode
+ Tricorder.CLI.UI.Keys: CycleTestView :: KeyEvent
+ Tricorder.CLI.UI.Keys: Quit :: KeyEvent
+ Tricorder.CLI.UI.Keys: RestartDaemon :: KeyEvent
+ Tricorder.CLI.UI.Keys: ScrollDown :: KeyEvent
+ Tricorder.CLI.UI.Keys: ScrollUp :: KeyEvent
+ Tricorder.CLI.UI.Keys: SwitchTabNext :: KeyEvent
+ Tricorder.CLI.UI.Keys: SwitchTabPrev :: KeyEvent
+ Tricorder.CLI.UI.Keys: ToggleHelp :: KeyEvent
+ Tricorder.CLI.UI.Keys: mkDispatcher :: forall (es :: [Effect]). (Console :> es, Exit :> es) => IO () -> KeyConfig KeyEvent -> Eff es (KeyDispatcher KeyEvent (EventM Viewports State))
+ Tricorder.CLI.UI.State: [showHelp] :: State -> Bool
+ Tricorder.Config: Config :: Maybe Config -> Config
+ Tricorder.Config: [session] :: Config -> Maybe Config
+ Tricorder.Config: asInput :: forall (es :: [Effect]) a. Input LoadedConfig :> es => Eff (Input (Either Text Config) ': es) a -> Eff es a
+ Tricorder.Config: data Config
+ Tricorder.Config: instance Data.Aeson.Types.FromJSON.FromJSON Tricorder.Config.Config
+ Tricorder.Config: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.Config.Config
+ Tricorder.Config: instance Data.Default.Internal.Default Tricorder.Config.Config
+ Tricorder.Config: instance GHC.Classes.Eq Tricorder.Config.Config
+ Tricorder.Config: instance GHC.Generics.Generic Tricorder.Config.Config
+ Tricorder.Config: instance GHC.Show.Show Tricorder.Config.Config
+ Tricorder.Config.LoadedConfig: LoadedConfig :: ByteString -> LoadedConfig
+ Tricorder.Config.LoadedConfig: [getLoadedConfig] :: LoadedConfig -> ByteString
+ Tricorder.Config.LoadedConfig: asInput :: forall (es :: [Effect]) a. (FileSystem :> es, Reader ProjectRoot :> es) => Eff (Input LoadedConfig ': es) a -> Eff es a
+ Tricorder.Config.LoadedConfig: asNestedInput :: forall (key :: Symbol) r (es :: [Effect]) a. (FromJSON r, Input LoadedConfig :> es, KnownSymbol key) => Eff (Input (Either Text r) ': es) a -> Eff es a
+ Tricorder.Config.LoadedConfig: configFileName :: FilePath
+ Tricorder.Config.LoadedConfig: decodeNestedValue :: forall (key :: Symbol) r. (FromJSON r, KnownSymbol key) => LoadedConfig -> Either Text r
+ Tricorder.Config.LoadedConfig: newtype LoadedConfig
+ Tricorder.Daemon.Ghci.Config: Config :: Second -> Second -> [Text] -> Config
+ Tricorder.Daemon.Ghci.Config: [extraSetupCommands] :: Config -> [Text]
+ Tricorder.Daemon.Ghci.Config: [shutdownTimeout] :: Config -> Second
+ Tricorder.Daemon.Ghci.Config: [startupTimeout] :: Config -> Second
+ Tricorder.Daemon.Ghci.Config: data Config
+ Tricorder.Daemon.Ghci.Config: instance Data.Default.Internal.Default Tricorder.Daemon.Ghci.Config.Config
+ Tricorder.Daemon.Ghci.Controls: Controls :: m LoadResult -> m () -> (FilePath -> m LoadResult) -> (Text -> m LoadResult) -> Controls (m :: Type -> Type)
+ Tricorder.Daemon.Ghci.Controls: [add] :: Controls (m :: Type -> Type) -> FilePath -> m LoadResult
+ Tricorder.Daemon.Ghci.Controls: [interrupt] :: Controls (m :: Type -> Type) -> m ()
+ Tricorder.Daemon.Ghci.Controls: [reload] :: Controls (m :: Type -> Type) -> m LoadResult
+ Tricorder.Daemon.Ghci.Controls: [unadd] :: Controls (m :: Type -> Type) -> Text -> m LoadResult
+ Tricorder.Daemon.Ghci.Controls: data Controls (m :: Type -> Type)
+ Tricorder.Daemon.Ghci.Controls: lift :: (forall a. () => m a -> n a) -> Controls m -> Controls n
+ Tricorder.Daemon.Ghci.Diagnostic: headerParser :: TextParser (Text, Position, Position, Text)
+ Tricorder.Daemon.Ghci.Diagnostic: unattributedFailure :: Diagnostic
+ Tricorder.Daemon.Ghci.InterruptDecision: NoOpIdle :: InterruptDecision
+ Tricorder.Daemon.Ghci.InterruptDecision: SendInterruptFor :: Int -> InterruptDecision
+ Tricorder.Daemon.Ghci.InterruptDecision: data InterruptDecision
+ Tricorder.Daemon.Ghci.InterruptDecision: decide :: State -> (State, InterruptDecision)
+ Tricorder.Daemon.Ghci.InterruptDecision: instance GHC.Classes.Eq Tricorder.Daemon.Ghci.InterruptDecision.InterruptDecision
+ Tricorder.Daemon.Ghci.InterruptDecision: instance GHC.Show.Show Tricorder.Daemon.Ghci.InterruptDecision.InterruptDecision
+ Tricorder.Daemon.Ghci.LineParser: LineStream :: [Text] -> LineStream
+ Tricorder.Daemon.Ghci.LineParser: instance Text.Megaparsec.Stream.Stream Tricorder.Daemon.Ghci.LineParser.LineStream
+ Tricorder.Daemon.Ghci.LineParser: newtype LineStream
+ Tricorder.Daemon.Ghci.LineParser: satisfyStripped :: (Text -> Bool) -> LineParser (Text, Text)
+ Tricorder.Daemon.Ghci.LineParser: stripAnsi :: Text -> Text
+ Tricorder.Daemon.Ghci.LineParser: type LineParser = Parsec Void LineStream
+ Tricorder.Daemon.Ghci.Load: LoadConfig :: FilePath -> Load
+ Tricorder.Daemon.Ghci.Load: Loading :: Loading -> Load
+ Tricorder.Daemon.Ghci.Load: Message :: Message -> Load
+ Tricorder.Daemon.Ghci.Load: Summary :: LoadOutcome -> Load
+ Tricorder.Daemon.Ghci.Load: data Load
+ Tricorder.Daemon.Ghci.Load: instance GHC.Classes.Eq Tricorder.Daemon.Ghci.Load.Load
+ Tricorder.Daemon.Ghci.Load: instance GHC.Show.Show Tricorder.Daemon.Ghci.Load.Load
+ Tricorder.Daemon.Ghci.Load: parser :: LineParser (Maybe Load)
+ Tricorder.Daemon.Ghci.Load: textParser :: TextParser Load
+ Tricorder.Daemon.Ghci.LoadOutcome: LoadFailed :: LoadOutcome
+ Tricorder.Daemon.Ghci.LoadOutcome: LoadSucceeded :: LoadOutcome
+ Tricorder.Daemon.Ghci.LoadOutcome: data LoadOutcome
+ Tricorder.Daemon.Ghci.LoadOutcome: instance GHC.Classes.Eq Tricorder.Daemon.Ghci.LoadOutcome.LoadOutcome
+ Tricorder.Daemon.Ghci.LoadOutcome: instance GHC.Show.Show Tricorder.Daemon.Ghci.LoadOutcome.LoadOutcome
+ Tricorder.Daemon.Ghci.LoadResult: LoadResult :: Int -> Set FilePath -> Map FilePath LoadedModule -> [Text] -> [Diagnostic] -> LoadResult
+ Tricorder.Daemon.Ghci.LoadResult: [compiledFiles] :: LoadResult -> Set FilePath
+ Tricorder.Daemon.Ghci.LoadResult: [diagnostics] :: LoadResult -> [Diagnostic]
+ Tricorder.Daemon.Ghci.LoadResult: [loadedModules] :: LoadResult -> Map FilePath LoadedModule
+ Tricorder.Daemon.Ghci.LoadResult: [moduleCount] :: LoadResult -> Int
+ Tricorder.Daemon.Ghci.LoadResult: [targetNames] :: LoadResult -> [Text]
+ Tricorder.Daemon.Ghci.LoadResult: data LoadResult
+ Tricorder.Daemon.Ghci.LoadResult: instance GHC.Classes.Eq Tricorder.Daemon.Ghci.LoadResult.LoadResult
+ Tricorder.Daemon.Ghci.LoadResult: instance GHC.Show.Show Tricorder.Daemon.Ghci.LoadResult.LoadResult
+ Tricorder.Daemon.Ghci.LoadResult: resolveKnownTargets :: Map FilePath LoadedModule -> LoadResult -> Map FilePath LoadedModule
+ Tricorder.Daemon.Ghci.LoadedModule: LoadedModule :: FilePath -> Text -> LoadedModule
+ Tricorder.Daemon.Ghci.LoadedModule: [moduleName] :: LoadedModule -> Text
+ Tricorder.Daemon.Ghci.LoadedModule: [relPath] :: LoadedModule -> FilePath
+ Tricorder.Daemon.Ghci.LoadedModule: data LoadedModule
+ Tricorder.Daemon.Ghci.LoadedModule: instance GHC.Classes.Eq Tricorder.Daemon.Ghci.LoadedModule.LoadedModule
+ Tricorder.Daemon.Ghci.LoadedModule: instance GHC.Show.Show Tricorder.Daemon.Ghci.LoadedModule.LoadedModule
+ Tricorder.Daemon.Ghci.Loading: Loading :: Int -> Int -> Text -> FilePath -> Loading
+ Tricorder.Daemon.Ghci.Loading: [index] :: Loading -> Int
+ Tricorder.Daemon.Ghci.Loading: [moduleName] :: Loading -> Text
+ Tricorder.Daemon.Ghci.Loading: [sourceFile] :: Loading -> FilePath
+ Tricorder.Daemon.Ghci.Loading: [total] :: Loading -> Int
+ Tricorder.Daemon.Ghci.Loading: data Loading
+ Tricorder.Daemon.Ghci.Loading: instance GHC.Classes.Eq Tricorder.Daemon.Ghci.Loading.Loading
+ Tricorder.Daemon.Ghci.Loading: instance GHC.Show.Show Tricorder.Daemon.Ghci.Loading.Loading
+ Tricorder.Daemon.Ghci.Message: Message :: Severity -> FilePath -> Position -> Position -> [Text] -> Message
+ Tricorder.Daemon.Ghci.Message: [endPos] :: Message -> Position
+ Tricorder.Daemon.Ghci.Message: [file] :: Message -> FilePath
+ Tricorder.Daemon.Ghci.Message: [messageLines] :: Message -> [Text]
+ Tricorder.Daemon.Ghci.Message: [severity] :: Message -> Severity
+ Tricorder.Daemon.Ghci.Message: [startPos] :: Message -> Position
+ Tricorder.Daemon.Ghci.Message: data Message
+ Tricorder.Daemon.Ghci.Message: instance GHC.Classes.Eq Tricorder.Daemon.Ghci.Message.Message
+ Tricorder.Daemon.Ghci.Message: instance GHC.Show.Show Tricorder.Daemon.Ghci.Message.Message
+ Tricorder.Daemon.Ghci.Output: parseShowModules :: [Text] -> [(Text, FilePath)]
+ Tricorder.Daemon.Ghci.Output: parseShowTargets :: [Text] -> [Text]
+ Tricorder.Daemon.Ghci.Path: isLocationLess :: FilePath -> Bool
+ Tricorder.Daemon.Ghci.Path: pathSuffixesAsModuleName :: FilePath -> [Text]
+ Tricorder.Daemon.Ghci.Position: Position :: Int -> Int -> Position
+ Tricorder.Daemon.Ghci.Position: [col] :: Position -> Int
+ Tricorder.Daemon.Ghci.Position: [line] :: Position -> Int
+ Tricorder.Daemon.Ghci.Position: data Position
+ Tricorder.Daemon.Ghci.Position: instance GHC.Classes.Eq Tricorder.Daemon.Ghci.Position.Position
+ Tricorder.Daemon.Ghci.Position: instance GHC.Show.Show Tricorder.Daemon.Ghci.Position.Position
+ Tricorder.Daemon.Ghci.Position: parser :: Parsec Void Text (Position, Position)
+ Tricorder.Daemon.Ghci.Process: MkUnexpectedExit :: Text -> Text -> UnexpectedExit
+ Tricorder.Daemon.Ghci.Process: Process :: Handle -> Handle -> Handle -> RunningProcess Handle Handle Handle -> TVar State -> Process
+ Tricorder.Daemon.Ghci.Process: StartupFailed :: Text -> Error
+ Tricorder.Daemon.Ghci.Process: StartupTimeout :: Error
+ Tricorder.Daemon.Ghci.Process: UnexpectedExit :: UnexpectedExit -> Error
+ Tricorder.Daemon.Ghci.Process: [errorMessage] :: UnexpectedExit -> Text
+ Tricorder.Daemon.Ghci.Process: [handle] :: Process -> RunningProcess Handle Handle Handle
+ Tricorder.Daemon.Ghci.Process: [marker] :: UnexpectedExit -> Text
+ Tricorder.Daemon.Ghci.Process: [stateVar] :: Process -> TVar State
+ Tricorder.Daemon.Ghci.Process: [stderr] :: Process -> Handle
+ Tricorder.Daemon.Ghci.Process: [stdin] :: Process -> Handle
+ Tricorder.Daemon.Ghci.Process: [stdout] :: Process -> Handle
+ Tricorder.Daemon.Ghci.Process: add :: forall (es :: [Effect]). (Conc :> es, Concurrent :> es, File :> es, Log :> es) => Process -> FilePath -> FilePath -> (Loading -> Eff es ()) -> Eff es LoadResult
+ Tricorder.Daemon.Ghci.Process: collectResult :: forall (es :: [Effect]). (Conc :> es, Concurrent :> es, File :> es, Log :> es) => Process -> [Text] -> FilePath -> Eff es LoadResult
+ Tricorder.Daemon.Ghci.Process: data Error
+ Tricorder.Daemon.Ghci.Process: data Process
+ Tricorder.Daemon.Ghci.Process: data UnexpectedExit
+ Tricorder.Daemon.Ghci.Process: drainUntil :: forall (es :: [Effect]). (File :> es, Log :> es) => Handle -> Text -> (Text -> Eff es ()) -> Eff es [Text]
+ Tricorder.Daemon.Ghci.Process: exec :: forall (es :: [Effect]). (Conc :> es, Concurrent :> es, File :> es, Log :> es) => Process -> Text -> (Loading -> Eff es ()) -> Eff es [Text]
+ Tricorder.Daemon.Ghci.Process: instance GHC.Classes.Eq Tricorder.Daemon.Ghci.Process.Error
+ Tricorder.Daemon.Ghci.Process: instance GHC.Classes.Eq Tricorder.Daemon.Ghci.Process.UnexpectedExit
+ Tricorder.Daemon.Ghci.Process: instance GHC.Exception.Type.Exception Tricorder.Daemon.Ghci.Process.Error
+ Tricorder.Daemon.Ghci.Process: instance GHC.Show.Show Tricorder.Daemon.Ghci.Process.Error
+ Tricorder.Daemon.Ghci.Process: instance GHC.Show.Show Tricorder.Daemon.Ghci.Process.UnexpectedExit
+ Tricorder.Daemon.Ghci.Process: interruptGhci :: forall (es :: [Effect]). (Process :> es, Concurrent :> es, File :> es) => Process -> Eff es ()
+ Tricorder.Daemon.Ghci.Process: reload :: forall (es :: [Effect]). (Conc :> es, Concurrent :> es, File :> es, Log :> es) => Process -> FilePath -> (Loading -> Eff es ()) -> Eff es LoadResult
+ Tricorder.Daemon.Ghci.Process: terminateProcess :: forall (es :: [Effect]). Process :> es => Process -> Eff es ()
+ Tricorder.Daemon.Ghci.Process: unadd :: forall (es :: [Effect]). (Conc :> es, Concurrent :> es, File :> es, Log :> es) => Process -> Text -> FilePath -> (Loading -> Eff es ()) -> Eff es LoadResult
+ Tricorder.Daemon.Ghci.Process: waitForBannerOrFail :: forall (es :: [Effect]). (Conc :> es, Concurrent :> es, File :> es, Timeout :> es) => Second -> Handle -> Handle -> Eff es ()
+ Tricorder.Daemon.Ghci.Process: with :: forall (es :: [Effect]) (stage :: Stage) a. (Process :> es, Conc :> es, Concurrent :> es, File :> es, Log :> es, Timeout :> es) => Config -> RenderedCommand stage -> FilePath -> (Loading -> Eff es ()) -> (Process -> Eff es ()) -> (Process -> [Text] -> Eff es a) -> Eff es a
+ Tricorder.Daemon.Ghci.Result: collectResult :: FilePath -> [Text] -> [(Text, FilePath)] -> [Text] -> LoadResult
+ Tricorder.Daemon.Ghci.Result: collectResultCustom :: FilePath -> [Load] -> [(Text, FilePath)] -> [Text] -> LoadResult
+ Tricorder.Daemon.Ghci.Result: extractTitle :: [String] -> Text
+ Tricorder.Daemon.Ghci.Result: parseProgressLine :: Text -> Maybe Loading
+ Tricorder.Daemon.Ghci.Result: parseReload :: [Text] -> [Load]
+ Tricorder.Daemon.Ghci.Result: reloadFailed :: [Load] -> Bool
+ Tricorder.Daemon.Ghci.Result: toAbsolute :: FilePath -> FilePath -> FilePath
+ Tricorder.Daemon.Ghci.Result: toRelative :: FilePath -> FilePath -> FilePath
+ Tricorder.Daemon.Ghci.Session: data Session (a :: Type -> Type) b
+ Tricorder.Daemon.Ghci.Session: run :: forall (es :: [Effect]) a. (Conc :> es, Concurrent :> es, File :> es, Log :> es, Process :> es, Timeout :> es) => Eff (Session ': es) a -> Eff es a
+ Tricorder.Daemon.Ghci.Session: runScripted :: forall (es :: [(Type -> Type) -> Type -> Type]) a. [Either SomeException LoadResult] -> Eff (Session ': es) a -> Eff es a
+ Tricorder.Daemon.Ghci.Session: with :: forall a (es :: [Effect]). (HasCallStack, Session :> es) => (BuildProgress -> Eff es ()) -> RenderedCommand 'Build -> ProjectRoot -> (LoadResult -> Controls (Eff es) -> Eff es a) -> Eff es a
+ Tricorder.Daemon.Ghci.Session.State: Busy :: Int -> State
+ Tricorder.Daemon.Ghci.Session.State: Idle :: Int -> State
+ Tricorder.Daemon.Ghci.Session.State: data State
+ Tricorder.Daemon.Ghci.Session.State: instance GHC.Classes.Eq Tricorder.Daemon.Ghci.Session.State.State
+ Tricorder.Daemon.Ghci.Session.State: instance GHC.Show.Show Tricorder.Daemon.Ghci.Session.State.State
+ Tricorder.Daemon.Ghci.Severity: Error :: Severity
+ Tricorder.Daemon.Ghci.Severity: Warning :: Severity
+ Tricorder.Daemon.Ghci.Severity: data Severity
+ Tricorder.Daemon.Ghci.Severity: instance GHC.Classes.Eq Tricorder.Daemon.Ghci.Severity.Severity
+ Tricorder.Daemon.Ghci.Severity: instance GHC.Classes.Ord Tricorder.Daemon.Ghci.Severity.Severity
+ Tricorder.Daemon.Ghci.Severity: instance GHC.Show.Show Tricorder.Daemon.Ghci.Severity.Severity
+ Tricorder.Daemon.Ghci.Severity: parse :: Text -> Maybe Severity
+ Tricorder.Daemon.Ghci.TextParser: run :: TextParser a -> Text -> Maybe a
+ Tricorder.Daemon.Ghci.TextParser: type TextParser = Parsec Void Text
+ Tricorder.Daemon.HookDispatch: main :: forall (es :: [Effect]). (Process :> es, Reader ProjectRoot :> es, Sub WatchedFile :> es) => Session -> Eff es Void
+ Tricorder.Session: display :: Session -> Text
+ Tricorder.Session: load :: forall (es :: [Effect]). (FileSystem :> es, Input (Either Text Config) :> es, Input [CabalFile] :> es, Log :> es, Reader ProjectRoot :> es) => Eff es (Either Text Session)
+ Tricorder.Session.CommandTemplate: display :: forall (stage :: Stage). CommandTemplate stage -> Text
+ Tricorder.Session.Config: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.Session.Config.Config
+ Tricorder.Session.HaskellHooks: HaskellHooks :: Maybe Hook -> Maybe Hook -> Maybe Hook -> HaskellHooks
+ Tricorder.Session.HaskellHooks: [fileAdded] :: HaskellHooks -> Maybe Hook
+ Tricorder.Session.HaskellHooks: [fileModified] :: HaskellHooks -> Maybe Hook
+ Tricorder.Session.HaskellHooks: [fileRemoved] :: HaskellHooks -> Maybe Hook
+ Tricorder.Session.HaskellHooks: data HaskellHooks
+ Tricorder.Session.HaskellHooks: display :: HaskellHooks -> Text
+ Tricorder.Session.HaskellHooks: instance Data.Aeson.Types.FromJSON.FromJSON Tricorder.Session.HaskellHooks.HaskellHooks
+ Tricorder.Session.HaskellHooks: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.Session.HaskellHooks.HaskellHooks
+ Tricorder.Session.HaskellHooks: instance Data.Default.Internal.Default Tricorder.Session.HaskellHooks.HaskellHooks
+ Tricorder.Session.HaskellHooks: instance GHC.Classes.Eq Tricorder.Session.HaskellHooks.HaskellHooks
+ Tricorder.Session.HaskellHooks: instance GHC.Generics.Generic Tricorder.Session.HaskellHooks.HaskellHooks
+ Tricorder.Session.HaskellHooks: instance GHC.Show.Show Tricorder.Session.HaskellHooks.HaskellHooks
+ Tricorder.Session.Hook: Hook :: Text -> Hook
+ Tricorder.Session.Hook: [getHook] :: Hook -> Text
+ Tricorder.Session.Hook: instance Data.Aeson.Types.FromJSON.FromJSON Tricorder.Session.Hook.Hook
+ Tricorder.Session.Hook: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.Session.Hook.Hook
+ Tricorder.Session.Hook: instance GHC.Classes.Eq Tricorder.Session.Hook.Hook
+ Tricorder.Session.Hook: instance GHC.Generics.Generic Tricorder.Session.Hook.Hook
+ Tricorder.Session.Hook: instance GHC.Show.Show Tricorder.Session.Hook.Hook
+ Tricorder.Session.Hook: newtype Hook
+ Tricorder.Session.Hook: run :: forall (es :: [Effect]). (Process :> es, Reader ProjectRoot :> es) => Hook -> Eff es ()
+ Tricorder.Session.Hooks: HookPair :: Maybe Hook -> Maybe Hook -> HookPair
+ Tricorder.Session.Hooks: [haskell] :: Hooks -> Maybe HaskellHooks
+ Tricorder.Session.Hooks: data HookPair
+ Tricorder.Session.Hooks: display :: Hooks -> Text
+ Tricorder.Session.Hooks: instance Data.Aeson.Types.FromJSON.FromJSON Tricorder.Session.Hooks.HookPair
+ Tricorder.Session.Hooks: instance Data.Aeson.Types.ToJSON.ToJSON Tricorder.Session.Hooks.HookPair
+ Tricorder.Session.Hooks: instance Data.Default.Internal.Default Tricorder.Session.Hooks.HookPair
+ Tricorder.Session.Hooks: instance GHC.Classes.Eq Tricorder.Session.Hooks.HookPair
+ Tricorder.Session.Hooks: instance GHC.Generics.Generic Tricorder.Session.Hooks.HookPair
+ Tricorder.Session.Hooks: instance GHC.Show.Show Tricorder.Session.Hooks.HookPair
+ Tricorder.Session.Stage.Build.Session: display :: BuildSession -> Text
+ Tricorder.Session.Stage.Eval.Session: display :: EvalSession -> Text
+ Tricorder.Session.Stage.Test.Session: display :: TestSession -> Text
+ Tricorder.Session.State: asInput :: forall (es :: [Effect]) a. State :> es => Eff (Input Result ': es) a -> Eff es a
+ Tricorder.Session.State: currentSession :: forall (es :: [Effect]). (HasCallStack, State :> es) => Eff es Result
+ Tricorder.Session.State: data State (a :: Type -> Type) b
+ Tricorder.Session.State: eval :: forall (es :: [Effect]) a. (Chan :> es, Clock :> es, Conc :> es, Concurrent :> es, Debounce FilePath :> es, FileSystem :> es, FileWatcher :> es, Input (Either Text Config) :> es, Input [CabalFile] :> es, Log :> es, Reader ProjectRoot :> es, Waiters :> es) => Eff (State ': es) a -> Eff es a
+ Tricorder.Session.State: withSession :: forall a (es :: [Effect]). (HasCallStack, State :> es) => (Result -> Eff es a) -> Eff es Void
+ Tricorder.Session.State.Invalid: Invalid :: Text -> Session -> Invalid
+ Tricorder.Session.State.Invalid: [fallback] :: Invalid -> Session
+ Tricorder.Session.State.Invalid: [reason] :: Invalid -> Text
+ Tricorder.Session.State.Invalid: data Invalid
+ Tricorder.Session.State.Invalid: instance GHC.Classes.Eq Tricorder.Session.State.Invalid.Invalid
+ Tricorder.Session.State.Result: Invalid :: Invalid -> Result
+ Tricorder.Session.State.Result: Ok :: Session -> Result
+ Tricorder.Session.State.Result: data Result
+ Tricorder.Session.State.Result: fromEither :: Either Text Session -> Result
+ Tricorder.Session.State.Result: instance GHC.Classes.Eq Tricorder.Session.State.Result.Result
+ Tricorder.Session.State.Result: toEither :: Result -> Either Invalid Session
+ Tricorder.Session.State.Result: toMaybe :: Result -> Maybe Session
+ Tricorder.Session.Util: displayList :: (a -> Text) -> [a] -> Text
+ Tricorder.Session.WatchDirs: allSourceDirs :: GenericPackageDescription -> [FilePath]
+ Tricorder.Socket.UnixSocket: accept :: forall (es :: [Effect]). (HasCallStack, UnixSocket :> es) => Socket -> Eff es Handle
+ Tricorder.Socket.UnixSocket: bind :: forall (es :: [Effect]). (HasCallStack, UnixSocket :> es) => FilePath -> Eff es Socket
+ Tricorder.Socket.UnixSocket: runIO :: forall (es :: [Effect]) a. (File :> es, IOE :> es) => Eff (UnixSocket ': es) a -> Eff es a
+ Tricorder.Socket.UnixSocket: runScripted :: forall (es :: [Effect]) a. (File :> es, IOE :> es) => [SocketScript] -> Eff (UnixSocket ': es) a -> Eff es a
+ Tricorder.SourceLookup: ProjectSourceDirs :: [FilePath] -> ProjectSourceDirs
+ Tricorder.SourceLookup: ReexportLimits :: Word -> Word -> ReexportLimits
+ Tricorder.SourceLookup: SourceReexported :: SourceQuery -> ModuleName -> Text -> ModuleSourceResult
+ Tricorder.SourceLookup: [maxDepth] :: ReexportLimits -> Word
+ Tricorder.SourceLookup: [maxModules] :: ReexportLimits -> Word
+ Tricorder.SourceLookup: data ReexportLimits
+ Tricorder.SourceLookup: defaultReexportLimits :: ReexportLimits
+ Tricorder.SourceLookup: instance GHC.Classes.Eq Tricorder.SourceLookup.ProjectSourceDirs
+ Tricorder.SourceLookup: instance GHC.Classes.Eq Tricorder.SourceLookup.ReexportLimits
+ Tricorder.SourceLookup: instance GHC.Show.Show Tricorder.SourceLookup.ProjectSourceDirs
+ Tricorder.SourceLookup: instance GHC.Show.Show Tricorder.SourceLookup.ReexportLimits
+ Tricorder.SourceLookup: newtype ProjectSourceDirs
+ Tricorder.SourceLookup: projectSourceDirs :: [CabalFile] -> ProjectSourceDirs
+ Tricorder.SourceLookup.Reexport: declaresMember :: Text -> Text -> Bool
+ Tricorder.SourceLookup.Reexport: exportsByName :: Text -> Text -> Bool
+ Tricorder.SourceLookup.Reexport: reexportCandidates :: Text -> Text -> [ModuleName]
+ Tricorder.SourceLookup.Tarball: sourceExtensions :: [Text]
- Tricorder.CLI.App: run :: forall (es :: [Effect]). (Brick :> es, BrickChan :> es, Cache (PackageId, SourceQuery) ModuleSourceResult :> es, Cache ModuleName PackageId :> es, Clock :> es, Conc :> es, Concurrent :> es, Console :> es, Daemons :> es, Delay :> es, Exit :> es, File :> es, FileSystem :> es, GhcPkg :> es, Hackage :> es, IOE :> es, Input DaemonInfo :> es, Input Repl :> es, Log :> es, PackageStore :> es, Process :> es, Reader Command :> es, Reader Config :> es, Reader LogPath :> es, Reader PidFile :> es, Reader SocketPath :> es, Timeout :> es, UnixSocket :> es) => Eff es ()
+ Tricorder.CLI.App: run :: forall (es :: [Effect]). (Brick :> es, BrickChan :> es, Clock :> es, Conc :> es, Concurrent :> es, Console :> es, Daemons :> es, Delay :> es, Exit :> es, File :> es, FileSystem :> es, GhcPkg :> es, Hackage :> es, IOE :> es, Input DaemonInfo :> es, Input Repl :> es, Input [CabalFile] :> es, Log :> es, PackageStore :> es, Process :> es, Reader Command :> es, Reader Config :> es, Reader LogPath :> es, Reader PidFile :> es, Reader SocketPath :> es, Timeout :> es, UnixSocket :> es) => Eff es ()
- Tricorder.CLI.Operations: showSource :: forall (es :: [Effect]). (Cache (PackageId, SourceQuery) ModuleSourceResult :> es, Cache ModuleName PackageId :> es, Console :> es, FileSystem :> es, GhcPkg :> es, Hackage :> es, Input Repl :> es, Log :> es, PackageStore :> es) => [SourceQuery] -> Eff es ()
+ Tricorder.CLI.Operations: showSource :: forall (es :: [Effect]). (Console :> es, FileSystem :> es, GhcPkg :> es, Hackage :> es, Input Repl :> es, Input [CabalFile] :> es, Log :> es, PackageStore :> es) => SourceOptions -> Eff es ()
- Tricorder.CLI.UI: viewUi :: forall (es :: [Effect]). (Brick :> es, BrickChan :> es, Clock :> es, Conc :> es, Concurrent :> es, Console :> es, Delay :> es, File :> es, Process :> es, Reader Config :> es, Reader SocketPath :> es, UnixSocket :> es) => Eff es ()
+ Tricorder.CLI.UI: viewUi :: forall (es :: [Effect]). (Brick :> es, BrickChan :> es, Clock :> es, Conc :> es, Concurrent :> es, Console :> es, Delay :> es, Exit :> es, File :> es, Process :> es, Reader Config :> es, Reader SocketPath :> es, UnixSocket :> es) => Eff es ()
- Tricorder.CLI.UI.Keys: mkKeyConfig :: forall (es :: [Effect]). (Console :> es, Reader Config :> es) => Eff es (KeyConfig KeyEvent)
+ Tricorder.CLI.UI.Keys: mkKeyConfig :: forall (es :: [Effect]). (Console :> es, Exit :> es, Reader Config :> es) => Eff es (KeyConfig KeyEvent)
- Tricorder.CLI.UI.State: State :: Processed Text BuildState -> TimeZone -> Route -> TestFilter -> State
+ Tricorder.CLI.UI.State: State :: Processed Text BuildState -> TimeZone -> Route -> TestFilter -> Bool -> State
- Tricorder.CLI.UI.View: view :: KeyConfig KeyEvent -> State -> [Widget Viewports]
+ Tricorder.CLI.UI.View: view :: forall (m :: Type -> Type). KeyConfig KeyEvent -> KeyDispatcher KeyEvent m -> State -> [Widget Viewports]
- Tricorder.Daemon.Builder: with :: forall (es :: [Effect]) a. (Clock :> es, GhciSession :> es, Log :> es, Pub BuildProgress :> es, Reader ProjectRoot :> es) => BuildId -> RenderedCommand 'Build -> WatchDirs -> (BuilderState -> NewLoadResult -> Eff (Builder ': es) a) -> Eff es (Either SomeException a)
+ Tricorder.Daemon.Builder: with :: forall (es :: [Effect]) a. (Clock :> es, Session :> es, Log :> es, Pub BuildProgress :> es, Reader ProjectRoot :> es) => BuildId -> RenderedCommand 'Build -> WatchDirs -> (BuilderState -> NewLoadResult -> Eff (Builder ': es) a) -> Eff es (Either SomeException a)
- Tricorder.Daemon.Core: main :: forall (es :: [Effect]). (Chan :> es, Clock :> es, Conc :> es, Concurrent :> es, Debounce FilePath :> es, EvalCommentRunner :> es, FileSystem :> es, FileWatcher :> es, GhciSession :> es, Hpack :> es, Input Session :> es, Log :> es, Process :> es, Pub BuildPhase :> es, Reader ProjectRoot :> es, State BuildId :> es, State IdleTimeout :> es, State Repl :> es, TestRunner :> es, Waiters :> es) => Eff es Void
+ Tricorder.Daemon.Core: main :: forall (es :: [Effect]). (Chan :> es, Clock :> es, Conc :> es, Concurrent :> es, EvalCommentRunner :> es, Session :> es, Log :> es, Process :> es, Pub BuildPhase :> es, Reader ProjectRoot :> es, State BuildId :> es, Sub CabalChangeDetected :> es, Sub SourceChangeDetected :> es, TestRunner :> es, Waiters :> es) => Session -> Eff es Void
- Tricorder.Daemon.Hpack: main :: forall (es :: [Effect]). (Chan :> es, Clock :> es, Conc :> es, Debounce FilePath :> es, FileWatcher :> es, Hpack :> es, Log :> es, Reader ProjectRoot :> es) => Eff es ()
+ Tricorder.Daemon.Hpack: main :: forall (es :: [Effect]). (Chan :> es, Clock :> es, Conc :> es, Debounce FilePath :> es, FileWatcher :> es, Hpack :> es, Log :> es, Reader ProjectRoot :> es) => Session -> Eff es ()
- Tricorder.Daemon.TestRunner: loadingToProgress :: GhciLoading -> Suite
+ Tricorder.Daemon.TestRunner: loadingToProgress :: Loading -> Suite
- Tricorder.Session.Hooks: Hooks :: Maybe Hook -> Maybe Hook -> Hooks
+ Tricorder.Session.Hooks: Hooks :: Maybe HookPair -> Maybe HookPair -> Maybe HaskellHooks -> Hooks
- Tricorder.Session.Hooks: [after] :: Hook -> Maybe Text
+ Tricorder.Session.Hooks: [after] :: HookPair -> Maybe Hook
- Tricorder.Session.Hooks: [before] :: Hook -> Maybe Text
+ Tricorder.Session.Hooks: [before] :: HookPair -> Maybe Hook
- Tricorder.Session.Hooks: [reload] :: Hooks -> Maybe Hook
+ Tricorder.Session.Hooks: [reload] :: Hooks -> Maybe HookPair
- Tricorder.Session.Hooks: [start] :: Hooks -> Maybe Hook
+ Tricorder.Session.Hooks: [start] :: Hooks -> Maybe HookPair
- Tricorder.Session.TestTarget: parse :: [Text] -> [TestTarget]
+ Tricorder.Session.TestTarget: parse :: Text -> Maybe TestTarget
- Tricorder.Session.TestTarget: project :: [Target] -> [TestTarget]
+ Tricorder.Session.TestTarget: project :: Target -> Maybe TestTarget
- Tricorder.Socket.Server: main :: forall (es :: [Effect]). (Conc :> es, Exit :> es, IdleTimer :> es, Input BuildId :> es, Log :> es, Reader SocketPath :> es, Sub BuildPhase :> es, UnixSocket :> es, Waiters :> es) => Eff es Void
+ Tricorder.Socket.Server: main :: forall (es :: [Effect]). (Conc :> es, Exit :> es, File :> es, FileSystem :> es, IdleTimer :> es, Input BuildId :> es, Log :> es, Reader SocketPath :> es, Sub BuildPhase :> es, UnixSocket :> es, Waiters :> es) => Eff es Void
- Tricorder.SourceLookup: lookupModuleSource :: forall (es :: [Effect]). (Cache (PackageId, SourceQuery) ModuleSourceResult :> es, Cache ModuleName PackageId :> es, FileSystem :> es, GhcPkg :> es, Hackage :> es, Input Repl :> es, Log :> es, PackageStore :> es) => SourceQuery -> Eff es ModuleSourceResult
+ Tricorder.SourceLookup: lookupModuleSource :: forall (es :: [Effect]). (FileSystem :> es, GhcPkg :> es, Hackage :> es, Input Repl :> es, Log :> es, PackageStore :> es) => ProjectSourceDirs -> ReexportLimits -> SourceQuery -> Eff es ModuleSourceResult

Files

CHANGELOG.md view
@@ -7,6 +7,73 @@  ## [Unreleased] +## [0.6.0.0] - 2026-10-20++### Added++- `tricorder test suites [TEST-TARGET...]` shows test suites, their test cases+  and their output. `--fields` picks what to show for each suite, e.g.+  `--fields 'name,cases(description,failure)'`; by default it shows a summary+  of each suite. `--failed-only` keeps only failed suites and their failed test+  cases. Text output is YAML, and `--json` gives JSON. This will replace+  `tricorder test-results`. Non-breaking.+- `tricorder source MODULE#SYMBOL` now follows re-exports: a symbol that+  `MODULE` re-exports rather than defines (e.g. `Data.Text#pack`, defined in+  `Data.Text.Internal`) is traced to its defining module, and its source is+  printed under a `-- Re-exported from <module>` line.+- `tricorder source` now takes optional `--max-depth N` (re-export hops to+  follow, default 5; `0` disables following) and `--max-modules N` (modules to+  read while following, default 24) to bound the re-export search.+- `tricorder source` now finds modules defined in the project itself, searching+  the `hs-source-dirs` of every component of every project package before+  falling back to installed packages.++### Changed++- Navigation in `tricorder ui` is now done with the left and right arrow keys+  (or `h` and `l`), where you switch to the left or right tab accordingly. The+  previous hotkey-based solution where you had to know which key related to+  which tab felt antiquated, and less intuitive compared to just moving between+  them left and right. You will get errors when starting Tricorder with old,+  unsupported key bindings in your `keybindings` config. These take the following form:++  ```+  Error(s) encountered when attempting to parse key bindings:+  Unrecognized key event: foo_bar+  ```++- Default key binding for the "help" view is now `?`. This key binding is+  supported as expected on keyboards where `?` is inputted with a modifier key.+- Configuration parsing issues are now properly surfaced to the user, and shown+  in `tricorder ui`, as well as being outputted to `stderr` for other commands.++### Fixed++- When the build command exits before GHCi starts, its output is now shown as+  plain text in `tricorder ui`, `tricorder status` and the JSON output.+  Previously it was rendered with `show`, so it appeared as+  `StartupFailed "..."` with line breaks printed as a literal `\n`.+  ([#542](https://github.com/tweag/tricorder/issues/542)) (Thanks @RyosukeDTomita)+- Pre-built binaries on GitHub were built against paths in `/nix/store`, and+  were not portable to systems that did not have Nix installed. The binaries+  are now correctly linked to be used on non-Nix machines, and NixOS machines+  with `nix-ld` enabled.+- Tricorder would sometimes not react to `.cabal` file changes, especially when+  there were existing build failures. This should now be fixed by reverting+  parts of an earlier change where the daemon's build state logic.+- Tricorder would sometimes keep old diagnostic messages from previous build+  failures around when files were moved. This would cause said diagnostic+  failures to persist until the daemon was manually restarted either through+  `tricorder restart`, or using `Shift+R` in `tricorder ui`. This was caused by+  handling the builder state in a layer above the builder restart logic,+  causing the builder state to persist between builder restarts (which happen+  when the `.cabal` file changes, like when adding or removing modules). This+  should now be fixed.++### Deprecated++- `tricorder test-results`: to be replaced by `tricorder test suites`.+ ## [0.5.0.0] - 2026-10-02  ### Added
src/Tricorder/Build/EvalComment.hs view
@@ -15,7 +15,7 @@     ) where -import Atelier.Types.QuietSnake (QuietSnake (..))+import Atelier.Types.JSON.QuietSnake (QuietSnake (..)) import Data.Aeson (FromJSON (..), ToJSON (..), Value (..), withObject, (.:)) import GHC.Generics (Generically (..)) import Text.Megaparsec
src/Tricorder/Build/Test.hs view
@@ -15,8 +15,8 @@     ) where -import Data.Aeson (FromJSON, ToJSON)-import GHC.Generics (Generically (..))+import Atelier.Types.JSON.QuietSnake (QuietSnake (..))+import Data.Aeson (FromJSON (..), ToJSON (..))  import Data.Map.Strict qualified as Map @@ -26,8 +26,7 @@  newtype Suites = Suites {getSuites :: Map TestTarget Suite}     deriving stock (Eq, Generic, Show)-    deriving (FromJSON, ToJSON) via Generically Suites-    deriving (Monoid, Semigroup) via Map TestTarget Suite+    deriving (FromJSON, Monoid, Semigroup, ToJSON) via Map TestTarget Suite   hasFailedTests :: Suites -> Bool@@ -58,7 +57,7 @@     | SuiteErrored SuiteError     | SuiteCompleted SuiteCompletion     deriving stock (Eq, Generic, Show)-    deriving (FromJSON, ToJSON) via (Generically Suite)+    deriving (FromJSON, ToJSON) via QuietSnake Suite   isFailedRun :: Suite -> Bool@@ -72,14 +71,14 @@     , total :: Int     }     deriving stock (Eq, Generic, Show)-    deriving (FromJSON, ToJSON) via Generically Progress+    deriving (FromJSON, ToJSON) via QuietSnake Progress   data Outcome     = Passed     | Failed Text     deriving stock (Eq, Generic, Show)-    deriving (FromJSON, ToJSON) via (Generically Outcome)+    deriving (FromJSON, ToJSON) via QuietSnake Outcome   data Case = Case@@ -87,7 +86,7 @@     , outcome :: Outcome     }     deriving stock (Eq, Generic, Show)-    deriving (FromJSON, ToJSON) via (Generically Case)+    deriving (FromJSON, ToJSON) via QuietSnake Case   caseFailed :: Case -> Bool@@ -99,7 +98,7 @@     { message :: Text     }     deriving stock (Eq, Generic, Show)-    deriving (FromJSON, ToJSON) via (Generically SuiteError)+    deriving (FromJSON, ToJSON) via QuietSnake SuiteError   data SuiteCompletion = SuiteCompletion@@ -109,4 +108,4 @@     , duration :: Maybe Duration     }     deriving stock (Eq, Generic, Show)-    deriving (FromJSON, ToJSON) via (Generically SuiteCompletion)+    deriving (FromJSON, ToJSON) via QuietSnake SuiteCompletion
src/Tricorder/CLI/App.hs view
@@ -1,6 +1,5 @@ module Tricorder.CLI.App (run) where -import Atelier.Effects.Cache (Cache) import Atelier.Effects.Clock (Clock) import Atelier.Effects.Conc (Conc) import Atelier.Effects.Console (Console)@@ -16,14 +15,13 @@ import Effectful (IOE) import Effectful.Concurrent (Concurrent) import Effectful.Reader.Static (Reader, ask, asks)-import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery)+import Tricorder.CLI.Command (Command (..), LogMode (..)) import Prelude hiding (force)  import Atelier.Effects.Console qualified as Console import Data.Text qualified as T import Tricorder.CLI.Command.Daemon qualified as DaemonCommand -import Tricorder.CLI.Arguments (Command (..), LogMode (..)) import Tricorder.CLI.Daemon     ( restartDaemon     , startDaemon@@ -43,23 +41,21 @@ import Tricorder.CLI.UI.BrickChan (BrickChan) import Tricorder.Daemon.DaemonInfo (DaemonInfo) import Tricorder.Runtime (LogPath (..), PidFile (..), SocketPath (..))+import Tricorder.Session.CabalFile (CabalFile) import Tricorder.Session.Repl (Repl) import Tricorder.Socket.Client (isDaemonRunning) import Tricorder.Socket.UnixSocket (UnixSocket)-import Tricorder.SourceLookup (ModuleSourceResult) import Tricorder.SourceLookup.GhcPkg (GhcPkg) import Tricorder.SourceLookup.Hackage (Hackage)-import Tricorder.SourceLookup.PackageId (PackageId) import Tricorder.SourceLookup.PackageStore (PackageStore) +import Tricorder.CLI.App.Test qualified as Test import Tricorder.CLI.UI.Keys qualified as Keys   run     :: ( Brick :> es        , BrickChan :> es-       , Cache (PackageId, SourceQuery) ModuleSourceResult :> es-       , Cache ModuleName PackageId :> es        , Clock :> es        , Conc :> es        , Concurrent :> es@@ -74,6 +70,7 @@        , IOE :> es        , Input DaemonInfo :> es        , Input Repl :> es+       , Input [CabalFile] :> es        , Log :> es        , PackageStore :> es        , Process :> es@@ -118,13 +115,14 @@                     Console.putStrLn "Stopped."                 else                     showStatus opts-        Test opts -> do+        TestResults opts -> do             running <- isDaemonRunning             if not running                 then                     Console.putStrLn "Stopped."                 else                     showTests opts+        Test testCmd -> Test.run testCmd         Log logMode -> do             logFile <- asks @LogPath (.getLogPath)             case logMode of@@ -136,8 +134,8 @@                 startDaemon                 void waitForDaemon             viewUi-        Source moduleNames -> do-            showSource moduleNames+        Source opts -> do+            showSource opts         Restart force ->             restartDaemon force >>= \case                 Just (Left reasons) -> traverse_ Console.putTextLn reasons
+ src/Tricorder/CLI/App/CLIResponse.hs view
@@ -0,0 +1,16 @@+{-# LANGUAGE UndecidableInstances #-}++module Tricorder.CLI.App.CLIResponse (CLIResponse (..)) where++import Data.Aeson (ToJSON)+import Data.Aeson.Text (encodeToLazyText)+++-- | This is effectively `Text.Show`, but for printing to the terminal.+class CLIResponse a where+    -- | Render the value in such a way that it displays nicely on a terminal.+    renderCLIResponse :: a -> LText+++instance (ToJSON a) => CLIResponse a where+    renderCLIResponse = encodeToLazyText
+ src/Tricorder/CLI/App/Output.hs view
@@ -0,0 +1,57 @@+module Tricorder.CLI.App.Output+    ( Output (..)+    , TextOutput (..)+    , JsonOutput (..)+    )+where++import Data.Aeson (ToJSON)+import Data.Aeson.Text (encodeToLazyText)+import System.Exit (ExitCode)++import Tricorder.CLI.App.CLIResponse (CLIResponse (..))+++data Output a+    = Fail ExitCode LText+    | Output a+++instance Functor Output where+    fmap f = \case+        Fail exitCode reason -> Fail exitCode reason+        Output val -> Output $ f val+++instance Applicative Output where+    pure = Output+    Fail exitCode reason <*> _ = Fail exitCode reason+    _ <*> Fail exitCode reason = Fail exitCode reason+    Output f <*> Output a = Output $ f a+++instance Monad Output where+    Fail exitCode reason >>= _ = Fail exitCode reason+    Output a >>= f = f a+++instance (CLIResponse a) => CLIResponse (Output a) where+    renderCLIResponse = \case+        Fail _ reason -> "Error: " <> reason+        Output val -> renderCLIResponse val+++-- | Textual output to the terminal.+data TextOutput a = TextOutput a+++instance (CLIResponse a) => CLIResponse (TextOutput a) where+    renderCLIResponse (TextOutput val) = renderCLIResponse val+++-- | JSON output to the terminal.+data JsonOutput a = JsonOutput a+++instance (ToJSON a) => CLIResponse (JsonOutput a) where+    renderCLIResponse (JsonOutput val) = encodeToLazyText val
+ src/Tricorder/CLI/App/Test.hs view
@@ -0,0 +1,145 @@+module Tricorder.CLI.App.Test (run) where++import Atelier.Effects.Console (Console)+import Atelier.Effects.Delay (Delay)+import Atelier.Effects.Exit (Exit, exitFailure)+import Atelier.Effects.File (File)+import Atelier.Effects.Posix.Daemons (Daemons, PidFile)+import Effectful (IOE)+import Effectful.Reader.Static (Reader)+import Tricorder.CLI.Command.IfDaemonIsStopped (IfDaemonIsStopped)+import Tricorder.CLI.Command.OutputFormat (OutputFormat (..))+import Tricorder.CLI.Command.Test (Command (..), SuitesOptions (..))+import Tricorder.CLI.Command.WaitMode (WaitMode (..))++import Atelier.Effects.Console qualified as Console+import Effectful.Reader.Static qualified as Reader+import Tricorder.CLI.Command.IfDaemonIsStopped qualified as IfDaemonIsStopped+import Tricorder.FieldMask.Schema qualified as FieldMask.Schema++import Tricorder.Build (BuildState (..))+import Tricorder.CLI.App.Test.SuiteView (defaultFields, schema)+import Tricorder.Runtime (SocketPath (..))+import Tricorder.Socket.Client (queryStatus, queryStatusWait)+import Tricorder.Socket.UnixSocket (UnixSocket)++import Tricorder.CLI.App.Test.Json qualified as Json+import Tricorder.CLI.App.Test.Text qualified as Text+import Tricorder.CLI.Daemon qualified as Daemons+import Tricorder.Session.TestTarget qualified as TestTarget+import Tricorder.Socket.Client qualified as Client+++run+    :: ( Console :> es+       , Daemons :> es+       , Delay :> es+       , Exit :> es+       , File :> es+       , IOE :> es+       , Reader PidFile :> es+       , Reader SocketPath :> es+       , UnixSocket :> es+       )+    => Command -> Eff es ()+run command =+    printResult =<< case command of+        Suites opts -> showSuites opts+++printResult :: (Console :> es, Exit :> es) => Either Text Text -> Eff es ()+printResult = \case+    Left err -> do+        Console.putTextLn err+        exitFailure+    Right res ->+        Console.putTextLn res+++showSuites+    :: ( Daemons :> es+       , Delay :> es+       , File :> es+       , IOE :> es+       , Reader PidFile :> es+       , Reader SocketPath :> es+       , UnixSocket :> es+       )+    => SuitesOptions -> Eff es (Either Text Text)+showSuites opts =+    case validated of+        Left err -> pure $ case opts.format of+            TextOutput -> Left $ "Error: " <> err+            JsonOutput -> Json.argumentError err+        Right (targets, selection) ->+            ensureDaemonRunning opts.ifDaemonIsStopped do+                result <- awaitBuildStatus opts.wait+                case opts.format of+                    TextOutput ->+                        pure+                            $ Text.wrapTextShow result+                            $ Text.showBasePhase+                            $ Text.showSuites targets opts.testFilter selection+                    JsonOutput ->+                        pure+                            $ Json.wrapResponse+                            $ Json.showBasePhase result+                            $ Json.showSuites targets opts.testFilter selection+  where+    validated = do+        targets <- traverse parseTarget opts.suiteNames+        selection <- FieldMask.Schema.select schema $ fromMaybe defaultFields opts.fields+        pure (targets, selection)+    parseTarget suiteName =+        maybeToRight+            ( "invalid test suite name: "+                <> suiteName+                <> ". Test suite names are expected to be in the format of a Cabal target."+            )+            $ TestTarget.parse suiteName+++awaitBuildStatus+    :: ( File :> es+       , Reader SocketPath :> es+       , UnixSocket :> es+       )+    => WaitMode -> Eff es (Either Text BuildState)+awaitBuildStatus wait = do+    SocketPath sockPath <- Reader.ask+    case wait of+        WaitForBuild -> queryStatusWait sockPath+        ShowCurrent -> queryStatus sockPath+++ensureDaemonRunning+    :: ( Daemons :> es+       , Delay :> es+       , IOE :> es+       , Reader PidFile :> es+       , Reader SocketPath :> es+       , UnixSocket :> es+       )+    => IfDaemonIsStopped -> Eff es (Either Text Text) -> Eff es (Either Text Text)+ensureDaemonRunning ifDaemonIsStopped continue = do+    isRunning <- Client.isDaemonRunning+    case (ifDaemonIsStopped, isRunning) of+        (IfDaemonIsStopped.Fail, False) -> pure $ Left "daemon is stopped"+        (IfDaemonIsStopped.StartDaemon, False) -> do+            ensureDaemonIsRunning+            continue+        (_, True) -> continue+++ensureDaemonIsRunning+    :: ( Daemons :> es+       , Delay :> es+       , IOE :> es+       , Reader PidFile :> es+       , Reader SocketPath :> es+       , UnixSocket :> es+       )+    => Eff es ()+ensureDaemonIsRunning = do+    Daemons.startDaemon+    void Daemons.waitForDaemon
+ src/Tricorder/CLI/App/Test/Json.hs view
@@ -0,0 +1,63 @@+module Tricorder.CLI.App.Test.Json+    ( showSuites+    , wrapResponse+    , showBasePhase+    , argumentError+    )+where++import Data.Aeson (ToJSON, toJSON)+import Data.Aeson.Text (encodeToLazyText)+import Tricorder.CLI.Command.TestFilter (TestFilter)++import Data.Text qualified as T+import Tricorder.FieldMask.Projection qualified as FieldMask.Projection+import Tricorder.FieldMask.Schema qualified as FieldMask (Schema)++import Tricorder.Build (BuildPhase (..), BuildState (..), PostBuild (..))+import Tricorder.CLI.App.Test.Json.Responses+    ( MessageWithLoc (..)+    , Response (..)+    , SuitesResponse (..)+    )+import Tricorder.CLI.App.Test.SuiteView (selectSuites)+import Tricorder.Session.TestTarget (TestTarget)++import Tricorder.Build.Test qualified as Test+import Tricorder.CLI.App.Test.Json.Responses qualified as Responses+import Tricorder.Session.TestTarget qualified as TestTarget+++showSuites+    :: [TestTarget] -> TestFilter -> FieldMask.Schema -> Test.Suites -> Response SuitesResponse+showSuites targets testFilter selection testSuites =+    case selectSuites targets testFilter testSuites of+        Left notFound ->+            Responses.Error+                $ MessageWithLoc (T.intercalate ", " $ TestTarget.render <$> toList notFound) "not found"+        Right suites -> Responses.Success $ SuitesResponse $ FieldMask.Projection.project selection . toJSON <$> suites+++wrapResponse :: (ToJSON a) => Response a -> Either Text Text+wrapResponse resp = case resp of+    Responses.Error _ -> Left $ toStrict $ encodeToLazyText resp+    _ -> Right $ toStrict $ encodeToLazyText resp+++-- | Report invalid arguments given to a command.+argumentError :: Text -> Either Text Text+argumentError err = wrapResponse @() $ Responses.Error $ MessageWithLoc "arguments" err+++showBasePhase :: Either Text BuildState -> (Test.Suites -> Response a) -> Response a+showBasePhase result mkResponse = case result of+    Left err -> Responses.Error $ MessageWithLoc "build" err+    Right build -> case build.phase of+        Starting -> Responses.Pending $ MessageWithLoc "build" "starting"+        Building _ _ -> Responses.Pending $ MessageWithLoc "build" "building"+        PostBuilding _ postBuild+            | Test.anyRunningTests postBuild.testSuites ->+                Responses.Pending $ MessageWithLoc "postBuild" "testing"+            | otherwise -> mkResponse postBuild.testSuites+        Finished _ postBuild -> mkResponse postBuild.testSuites+        Failed reason -> Responses.Error $ MessageWithLoc "build" $ "build failed: " <> reason
+ src/Tricorder/CLI/App/Test/Json/Responses.hs view
@@ -0,0 +1,79 @@+module Tricorder.CLI.App.Test.Json.Responses+    ( Response (..)+    , MessageWithLoc (..)+    , SuitesResponse (..)+    )+where++import Atelier.Types.JSON.QuietSnake (QuietSnake (..))+import Data.Aeson+    ( FromJSON+    , Options (..)+    , SumEncoding (..)+    , ToJSON (..)+    , defaultOptions+    , genericToEncoding+    , genericToJSON+    , object+    , pairs+    , (.=)+    )+import Text.Casing (quietSnake)++import Tricorder.FieldMask.Projection qualified as FieldMask (Projection)+++data Response a+    = Error MessageWithLoc+    | Pending MessageWithLoc+    | Success a+    deriving stock (Eq, Functor, Generic, Show)+++instance Applicative Response where+    pure = Success+    Error message <*> _ = Error message+    _ <*> Error message = Error message+    Pending message <*> _ = Pending message+    _ <*> Pending message = Pending message+    Success f <*> Success a = Success $ f a+++instance Monad Response where+    Error message >>= _ = Error message+    Pending message >>= _ = Pending message+    Success a >>= f = f a+++instance (ToJSON a) => ToJSON (Response a) where+    toJSON = genericToJSON responseOptions+    toEncoding = genericToEncoding responseOptions+++data MessageWithLoc = MessageWithLoc+    { location :: Text+    , message :: Text+    }+    deriving stock (Eq, Generic, Show)+    deriving (FromJSON, ToJSON) via QuietSnake MessageWithLoc+++responseOptions :: Options+responseOptions =+    defaultOptions+        { constructorTagModifier = quietSnake+        , sumEncoding = ObjectWithSingleField+        }+++-- | Test suites, cut down to the requested fields.+newtype SuitesResponse = SuitesResponse+    { suites :: [FieldMask.Projection]+    }+    deriving stock (Eq, Show)+++-- | Written by hand so 'toEncoding' keeps the field order of the projection.+instance ToJSON SuitesResponse where+    toJSON response = object ["suites" .= response.suites]+    toEncoding response = pairs ("suites" .= response.suites)
+ src/Tricorder/CLI/App/Test/SuiteView.hs view
@@ -0,0 +1,154 @@+-- | The full, unprojected view of a test suite that @tricorder test suites@+-- selects fields from.+module Tricorder.CLI.App.Test.SuiteView+    ( SuiteView (..)+    , Status (..)+    , CaseView (..)+    , Outcome (..)+    , schema+    , defaultFields+    , selectSuites+    )+where++import Atelier.Types.JSON.QuietSnake (QuietSnake (..))+import Data.Aeson (ToJSON (..))+import Tricorder.CLI.Command.TestFilter (TestFilter)+import Tricorder.FieldMask (Field (..), FieldMask (..))++import Data.Map.Strict qualified as Map+import Data.Text qualified as T+import Tricorder.CLI.Command.TestFilter qualified as TestFilter+import Tricorder.FieldMask.Schema qualified as FieldMask (Schema (..))+import Tricorder.FieldMask.Schema qualified as FieldMask.Schema++import Tricorder.Build.Duration (Duration)+import Tricorder.Session.TestTarget (TestTarget)+import Tricorder.TestOutput (stripGhciNoise)++import Tricorder.Build.Test qualified as Test+import Tricorder.Session.TestTarget qualified as TestTarget+++-- | Fields that do not apply to a suite's 'Status' are 'Nothing', and are+-- left out of the output.+data SuiteView = SuiteView+    { name :: Text+    , status :: Status+    , error :: Maybe Text+    , passedCases :: Maybe Int+    , totalCases :: Maybe Int+    , durationMs :: Maybe Duration+    , output :: Maybe Text+    , cases :: Maybe [CaseView]+    }+    deriving stock (Eq, Generic, Show)+    deriving (ToJSON) via QuietSnake SuiteView+++data Status = Running | Errored | Passed | Failed+    deriving stock (Eq, Generic, Show)+    deriving (ToJSON) via QuietSnake Status+++data CaseView = CaseView+    { description :: Text+    , outcome :: Outcome+    , failure :: Maybe Text+    }+    deriving stock (Eq, Generic, Show)+    deriving (ToJSON) via QuietSnake CaseView+++data Outcome = CasePassed | CaseFailed+    deriving stock (Eq, Generic, Show)+++instance ToJSON Outcome where+    toJSON = \case+        CasePassed -> "passed"+        CaseFailed -> "failed"+++-- | Every field of a 'SuiteView', as named in its JSON encoding.+schema :: FieldMask.Schema+schema =+    FieldMask.Schema+        [ FieldMask.Schema.leaf "name"+        , FieldMask.Schema.leaf "status"+        , FieldMask.Schema.leaf "error"+        , FieldMask.Schema.leaf "passed_cases"+        , FieldMask.Schema.leaf "total_cases"+        , FieldMask.Schema.leaf "duration_ms"+        , FieldMask.Schema.leaf "output"+        , FieldMask.Schema.nested+            "cases"+            [ FieldMask.Schema.leaf "description"+            , FieldMask.Schema.leaf "outcome"+            , FieldMask.Schema.leaf "failure"+            ]+        ]+++-- | A summary of each suite, leaving out the (potentially large) test output+-- and test cases.+defaultFields :: FieldMask+defaultFields =+    FieldMask+        $ (\name -> Field name Nothing)+            <$> "name" :| ["status", "error", "passed_cases", "total_cases", "duration_ms"]+++-- | Pick out the requested suites, or all of them when none are requested.+-- Fails with the requested suites that do not exist.+--+-- 'TestFilter.FailedOnly' keeps only failed suites, and only the failed test+-- cases within them.+selectSuites+    :: [TestTarget] -> TestFilter -> Test.Suites -> Either (NonEmpty TestTarget) [SuiteView]+selectSuites requested testFilter (Test.Suites suites) = do+    selected <- case requested of+        [] -> Right $ Map.toList suites+        _ -> case nonEmpty missing of+            Just notFound -> Left notFound+            Nothing -> Right [(target, suite) | target <- ordNub requested, Just suite <- [Map.lookup target suites]]+    pure $ uncurry toView <$> filter (keepSuite . snd) selected+  where+    missing = filter (`Map.notMember` suites) requested++    keepSuite suite = case testFilter of+        TestFilter.FailedOnly -> Test.isFailedRun suite+        TestFilter.All -> True++    keepCase testCase = case testFilter of+        TestFilter.FailedOnly -> Test.caseFailed testCase+        TestFilter.All -> True++    toView target suite =+        let view =+                SuiteView+                    { name = TestTarget.render target+                    , status = Running+                    , error = Nothing+                    , passedCases = Nothing+                    , totalCases = Nothing+                    , durationMs = Nothing+                    , output = Nothing+                    , cases = Nothing+                    }+        in  case suite of+                Test.SuiteRunning _ -> view+                Test.SuiteErrored err -> view {status = Errored, error = Just err.message}+                Test.SuiteCompleted completion ->+                    view+                        { status = if completion.passed then Passed else Failed+                        , passedCases = Just $ length $ filter (not . Test.caseFailed) completion.testCases+                        , totalCases = Just $ length completion.testCases+                        , durationMs = completion.duration+                        , output = Just $ T.unlines $ stripGhciNoise $ T.lines completion.output+                        , cases = Just $ toCaseView <$> filter keepCase completion.testCases+                        }++    toCaseView testCase = case testCase.outcome of+        Test.Passed -> CaseView testCase.description CasePassed Nothing+        Test.Failed reason -> CaseView testCase.description CaseFailed (Just reason)
+ src/Tricorder/CLI/App/Test/Text.hs view
@@ -0,0 +1,99 @@+module Tricorder.CLI.App.Test.Text+    ( showSuites+    , wrapTextShow+    , showBasePhase+    )+where++import Data.Aeson (toJSON)+import Tricorder.CLI.Command.TestFilter (TestFilter)++import Data.Text qualified as T+import Tricorder.CLI.Command.TestFilter qualified as TestFilter+import Tricorder.FieldMask.Projection qualified as FieldMask.Projection+import Tricorder.FieldMask.Schema qualified as FieldMask++import Tricorder.Build+    ( BuildPhase (..)+    , BuildProgress (..)+    , BuildResult (..)+    , BuildState (..)+    , Diagnostic (..)+    , PostBuild (..)+    , Severity (..)+    )+import Tricorder.CLI.App.Test.SuiteView (selectSuites)+import Tricorder.Session.TestTarget (TestTarget)++import Tricorder.Build.Test qualified as Test+import Tricorder.Session.TestTarget qualified as TestTarget+++showSuites :: [TestTarget] -> TestFilter -> FieldMask.Schema -> Test.Suites -> Either Text Text+showSuites targets testFilter selection testSuites =+    case selectSuites targets testFilter testSuites of+        Left notFound ->+            Left $ "test suites not found: " <> T.intercalate ", " (TestTarget.render <$> toList notFound)+        Right []+            | Test.nullSuites testSuites -> Right "no test suites to run"+            | TestFilter.FailedOnly <- testFilter -> Right "no failed test suites"+        Right suites ->+            Right+                $ T.stripEnd+                $ FieldMask.Projection.renderYaml+                $ FieldMask.Projection.PArray+                $ FieldMask.Projection.project selection . toJSON <$> suites+++wrapTextShow :: Either Text BuildState -> (BuildPhase -> Either Text Text) -> Either Text Text+wrapTextShow = \case+    Left err -> const $ Left $ "Error: " <> err+    Right build -> ($ build.phase)+++showBasePhase :: (Test.Suites -> Either Text Text) -> BuildPhase -> Either Text Text+showBasePhase showTests = \case+    Starting -> Right "Starting..."+    Building _ progress ->+        Right+            $ mconcat+                [ "Building ("+                , show progress.compiled+                , "/"+                , show progress.total+                , ")..."+                ]+    PostBuilding result postBuild+        | buildFailed result postBuild ->+            Left $ "build failed before tests ran"+        | Test.anyRunningTests postBuild.testSuites ->+            Right $ showTestProgress postBuild.testSuites+        | otherwise ->+            showTests postBuild.testSuites+    Finished result postBuild+        | buildFailed result postBuild ->+            Left $ "build failed before tests ran"+        | otherwise ->+            showTests postBuild.testSuites+    Failed err -> Left $ "Build failed: " <> err+  where+    buildFailed result postBuild = postBuild.testSuites == mempty && any (\d -> d.severity >= SError) result.diagnostics+++showTestProgress :: Test.Suites -> Text+showTestProgress suites =+    mconcat+        [ "Testing (test suites finished: "+        , show+            $ length+            $ filter+                ( \case+                    Test.SuiteCompleted _ -> True+                    Test.SuiteErrored _ -> True+                    _ -> False+                )+            $ toList suites.getSuites+        , "/"+        , show $ length $ suites.getSuites+        , ")"+        ]
src/Tricorder/CLI/Arguments.hs view
@@ -1,12 +1,5 @@ module Tricorder.CLI.Arguments-    ( Command (..)-    , LogMode (..)-    , StatusOptions (..)-    , TestOptions (..)-    , EvalCommentsOptions (..)-    , Verbosity (..)-    , WaitMode (..)-    , parseArguments+    ( parseArguments     , runArguments     ) where@@ -33,6 +26,8 @@     , metavar     , option     , progDesc+    , progDescDoc+    , readerError     , short     ) import Tricorder.CLI.Command@@ -40,15 +35,21 @@     , EvalCommentsOptions (..)     , Force (..)     , LogMode (..)+    , SourceOptions (..)     , StatusOptions (..)     , TestOptions (..)     , Verbosity (..)-    , WaitMode (..)     ) import Tricorder.SourceLookup.SourceQuery (SourceQuery, parseSourceQuery) +import Options.Applicative.Help.Pretty qualified as Doc++import Tricorder.SourceLookup (ReexportLimits (..), defaultReexportLimits)+ import Tricorder.CLI.Arguments.Daemon qualified as Daemon import Tricorder.CLI.Arguments.OutputFormat qualified as OutputFormat+import Tricorder.CLI.Arguments.Test qualified as Test+import Tricorder.CLI.Arguments.WaitMode qualified as WaitMode import Tricorder.Version qualified as Version  @@ -82,12 +83,23 @@             <> command                 "status"                 (info statusParser (progDesc "Print build diagnostics (--json for machine-readable output)"))-            <> command "test-results" (info testParser (progDesc "Show output from the latest test run"))+            <> command+                "test-results"+                ( info+                    testResultsParser+                    ( progDescDoc+                        $ Just+                        $ "Show output from the latest test run"+                            <> Doc.line+                            <> Doc.annotate Doc.bold "DEPRECATED: use the `test suites` subcommand"+                    )+                )+            <> command "test" (Test <$> Test.parser)             <> command "ui" (info (pure UI) (progDesc "Auto-refreshing terminal display"))             <> command "log" (info logParser (progDesc "Show daemon log output"))             <> command                 "source"-                (info sourceParser (progDesc "Print the Haskell source of one or more installed modules"))+                (info sourceParser (progDesc "Print the Haskell source of one or more project or installed modules"))             <> command "restart" (info restartParser (progDesc "Restart the daemon"))             <> command                 "eval-comments"@@ -113,7 +125,7 @@ statusParser =     Status         <$> ( StatusOptions-                <$> waitParser+                <$> WaitMode.parser                 <*> OutputFormat.parser                 <*> flag                     Concise@@ -133,9 +145,9 @@             )  -testParser :: Parser Command-testParser =-    Test+testResultsParser :: Parser Command+testResultsParser =+    TestResults         <$> ( TestOptions                 <$> flag                     False@@ -143,14 +155,48 @@                     ( long "failed"                         <> help "Only show output from failed test suites"                     )-                <*> waitParser+                <*> WaitMode.parser             )   sourceParser :: Parser Command sourceParser =     Source-        <$> some (argument queryReader (metavar "MODULE[#FUNCTION]" <> help "Module or Module#function"))+        <$> ( SourceOptions+                <$> some+                    ( argument+                        queryReader+                        (metavar "Module[#function]" <> help "Module or Module#function")+                    )+                <*> optional+                    ( option+                        nonNegative+                        ( long "max-depth"+                            <> metavar "N"+                            <> help+                                ( "Follow at most N re-export hops when a symbol is not defined in the queried module (default: "+                                    <> show defaultReexportLimits.maxDepth+                                    <> ")"+                                )+                        )+                    )+                <*> optional+                    ( option+                        nonNegative+                        ( long "max-modules"+                            <> metavar "N"+                            <> help+                                ( "Read at most N modules while following re-exports (default: "+                                    <> show defaultReexportLimits.maxModules+                                    <> ")"+                                )+                        )+                    )+            )+  where+    nonNegative = do+        n <- auto+        if n < 0 then readerError "must be a non-negative integer" else pure n   stopParser :: Parser Command@@ -171,19 +217,9 @@ evalCommentsParser =     EvalComments         <$> ( EvalCommentsOptions-                <$> waitParser+                <$> WaitMode.parser                 <*> OutputFormat.parser             )---waitParser :: Parser WaitMode-waitParser =-    flag-        ShowCurrent-        WaitForBuild-        ( long "wait"-            <> help "Block until the current build cycle completes"-        )   queryReader :: ReadM SourceQuery
+ src/Tricorder/CLI/Arguments/IfDaemonIsStopped.hs view
@@ -0,0 +1,14 @@+module Tricorder.CLI.Arguments.IfDaemonIsStopped (parser) where++import Options.Applicative (Parser, flag, help, long, short)+import Tricorder.CLI.Command.IfDaemonIsStopped (IfDaemonIsStopped (..), startDaemonFlagName)+++parser :: Parser IfDaemonIsStopped+parser =+    flag+        Fail+        StartDaemon+        $ long startDaemonFlagName+            <> short 'd'+            <> help "Start the daemon if it is not already running."
src/Tricorder/CLI/Arguments/OutputFormat.hs view
@@ -1,7 +1,7 @@ module Tricorder.CLI.Arguments.OutputFormat (parser) where -import Options.Applicative (Parser, flag, help, long)-import Tricorder.CLI.Command.OutputFormat (OutputFormat (..))+import Options.Applicative (Parser, flag, help, long, short)+import Tricorder.CLI.Command.OutputFormat (OutputFormat (..), jsonOutputFlagName)   parser :: Parser OutputFormat@@ -9,4 +9,6 @@     flag         TextOutput         JsonOutput-        $ long "json" <> help "Output full build state as JSON"+        $ long jsonOutputFlagName+            <> short 'j'+            <> help "Output full build state as JSON"
+ src/Tricorder/CLI/Arguments/Test.hs view
@@ -0,0 +1,106 @@+module Tricorder.CLI.Arguments.Test (parser) where++import Options.Applicative+    ( Parser+    , ParserInfo+    , command+    , footer+    , help+    , helpDoc+    , hsubparser+    , info+    , metavar+    , progDesc+    , strArgument+    )+import Tricorder.CLI.Command.Test (Command, SuitesOptions (..))++import Data.Text qualified as T+import Options.Applicative.Help.Pretty qualified as Doc+import Tricorder.CLI.Command.Test qualified as Test+import Tricorder.FieldMask qualified as FieldMask+import Tricorder.FieldMask.Args qualified as FieldMask.Args+import Tricorder.FieldMask.Schema qualified as FieldMask (Schema (..))++import Tricorder.CLI.App.Test.SuiteView qualified as SuiteView+import Tricorder.CLI.Arguments.IfDaemonIsStopped qualified as IfDaemonIsStopped+import Tricorder.CLI.Arguments.OutputFormat qualified as OutputFormat+import Tricorder.CLI.Arguments.TestFilter qualified as TestFilter+import Tricorder.CLI.Arguments.WaitMode qualified as WaitMode+++parser :: ParserInfo Command+parser =+    info commandParser (progDesc "See statuses and information on configured test suites.")+++commandParser :: Parser Command+commandParser =+    hsubparser+        $ command+            "suites"+            ( info+                suitesParser+                ( progDesc "Show test suites, their test cases and output."+                    <> footer suitesFooter+                )+            )+++suitesParser :: Parser Command+suitesParser =+    fmap Test.Suites+        $ SuitesOptions+            <$> many (strArgument (metavar "TEST-TARGET..." <> help "Test suites to show (default: all)"))+            <*> TestFilter.parser (help "Show only failed test suites, and only their failed test cases")+            <*> FieldMask.Args.parser+                ( helpDoc+                    $ Just+                    $ (<> Doc.line)+                    $ mconcat+                    $ Doc.punctuate+                        (Doc.line <> Doc.line)+                        [ "Fields to return for each test suite."+                        , "Available fields:"+                            <> Doc.line+                            <> Doc.indent 4 (Doc.vsep $ renderSchema SuiteView.schema)+                        , "Select nested fields with parentheses,"+                            <> Doc.line+                            <> "e.g. --fields 'name,cases(description,failure)'."+                            <> Doc.line+                            <> "A field without parentheses shows all of its nested fields."+                        , "Default fields:"+                            <> Doc.line+                            <> Doc.pretty (FieldMask.render SuiteView.defaultFields)+                        ]+                )+            <*> OutputFormat.parser+            <*> WaitMode.parser+            <*> IfDaemonIsStopped.parser+  where+    renderSchema (FieldMask.Schema fields) = renderField <$> fields+    renderField (name, sub) =+        Doc.pretty name+            <> maybe+                Doc.emptyDoc+                ( \nestedSchema ->+                    Doc.enclose+                        "("+                        ")"+                        $ Doc.sep+                        $ Doc.punctuate ","+                        $ renderSchema nestedSchema+                )+                sub+++suitesFooter :: String+suitesFooter =+    toString+        $ "Available fields:\n"+            <> renderSchema SuiteView.schema+            <> "\nSelect nested fields with parentheses, e.g. --fields 'name,cases(description,failure)'."+            <> " A field without parentheses shows all of its nested fields."+  where+    renderSchema (FieldMask.Schema fields) = T.intercalate ", " $ renderField <$> fields+    renderField (name, sub) = name <> maybe "" (\nestedSchema -> "(" <> renderSchema nestedSchema <> ")") sub
+ src/Tricorder/CLI/Arguments/TestFilter.hs view
@@ -0,0 +1,17 @@+module Tricorder.CLI.Arguments.TestFilter (parser) where++import Options.Applicative (FlagFields, Mod, Parser, flag, long, short)+import Tricorder.CLI.Command.TestFilter+    ( TestFilter (..)+    , failedOnlyFlagName+    , failedOnlyFlagShortName+    )+import Prelude hiding (All (..))+++parser :: Mod FlagFields TestFilter -> Parser TestFilter+parser extraMods =+    flag All FailedOnly+        $ long failedOnlyFlagName+            <> short failedOnlyFlagShortName+            <> extraMods
+ src/Tricorder/CLI/Arguments/WaitMode.hs view
@@ -0,0 +1,12 @@+module Tricorder.CLI.Arguments.WaitMode (parser) where++import Options.Applicative (Parser, flag, help, long, short)+import Tricorder.CLI.Command.WaitMode (WaitMode (..), waitForBuildFlagName)+++parser :: Parser WaitMode+parser =+    flag ShowCurrent WaitForBuild+        $ long waitForBuildFlagName+            <> short 'w'+            <> help "Block until the current build cycle completes"
src/Tricorder/CLI/Main.hs view
@@ -1,16 +1,14 @@ module Tricorder.CLI.Main (main) where -import Atelier.Config (runConfig) import Atelier.Effects.Arguments (runArgumentsIO) import Atelier.Effects.Clock (runClock) import Atelier.Effects.Conc (runConc)-import Atelier.Effects.Console (runConsole)+import Atelier.Effects.Console (Console, runConsole, runConsoleHandle) import Atelier.Effects.Delay (runDelay) import Atelier.Effects.Env (runEnv)-import Atelier.Effects.Exit (runExit)+import Atelier.Effects.Exit (exitFailure, runExit) import Atelier.Effects.File (runFile) import Atelier.Effects.FileSystem (runFileSystemIO)-import Atelier.Effects.Input (input, runInputEff) import Atelier.Effects.Log (runLogNoOp) import Atelier.Effects.Posix.Daemons (runDaemons) import Atelier.Effects.Process (runProcessIO)@@ -19,30 +17,29 @@ import Data.Default (def) import Effectful (runEff) import Effectful.Concurrent (runConcurrent)-import Effectful.Reader.Static (runReader)-import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery)+import Effectful.Provider (provideWith_, runProvider_)+import System.IO (stderr) -import Atelier.Effects.Cache qualified as Cache-import Atelier.Effects.Cache qualified as CacheConfig+import Atelier.Effects.Console qualified as Console import Atelier.Effects.FileSystem.Glob qualified as Glob+import Atelier.Effects.Input qualified as Input  import Tricorder.CLI.Arguments (runArguments) import Tricorder.CLI.UI.Brick (runBrick) import Tricorder.CLI.UI.BrickChan (runBrickChan)-import Tricorder.Config (inputLoadedConfig, runLoadedConfig)+import Tricorder.Config (asInput) import Tricorder.Runtime (runLogPath, runPidFile, runProjectRoot, runRuntimeDir, runSocketPath)-import Tricorder.Session (Session (..), loadSession) import Tricorder.Session.CabalFile (inputCabalFiles) import Tricorder.Session.CommandTemplate (CommandTemplate (..)) import Tricorder.Session.Stage.Build.Session (BuildSession (..))-import Tricorder.Socket.UnixSocket (runUnixSocketIO)-import Tricorder.SourceLookup.PackageId (PackageId)  import Tricorder.CLI.App qualified as App import Tricorder.CLI.UI.Keys qualified as Keys+import Tricorder.Config.LoadedConfig qualified as LoadedConfig import Tricorder.Daemon.DaemonInfo qualified as DaemonInfo+import Tricorder.Session qualified as Session import Tricorder.Session.StackProject qualified as StackProject-import Tricorder.SourceLookup qualified as SourceLookup+import Tricorder.Socket.UnixSocket qualified as UnixSocket import Tricorder.SourceLookup.GhcPkg qualified as GhcPkg import Tricorder.SourceLookup.Hackage qualified as Hackage import Tricorder.SourceLookup.PackageStore qualified as PackageStore@@ -57,6 +54,7 @@         . runBrickChan         . runBrick         . runConsole+        . runProvider_ runConsoleHandle         . runExit         . runClock         . runDelay@@ -68,27 +66,34 @@         . runPidFile         . runSocketPath         . runLogPath-        . runLoadedConfig-        . runConfig @"keybindings" @Keys.Config+        . LoadedConfig.asInput+        . LoadedConfig.asNestedInput @"keybindings" @Keys.Config+        . Input.map (either @_ @Keys.Config (const def) id)+        . Input.toReader @Keys.Config         . runDaemons         . runProcessIO         . runArgumentsIO         . runArguments-        . runUnixSocketIO+        . UnixSocket.runIO         . runEnv-        . inputLoadedConfig         . runLogNoOp         . StackProject.inputWithCache         . inputCabalFiles-        . runInputEff loadSession-        . runInputEff ((.buildSession.commandTemplate.repl) <$> input)-        . runReader @CacheConfig.Config def-        . Cache.runCacheTtl @ModuleName @PackageId-        . Cache.runCacheTtl @(PackageId, SourceQuery) @SourceLookup.ModuleSourceResult-        . GhcPkg.runGhcPkgIO-        . PackageStore.run-        . Hackage.run-        . DaemonInfo.runInput+        . Tricorder.Config.asInput         $ do-            installTerminationHandler-            App.run+            Session.load >>= \case+                Left err -> do+                    provideWith_ @Console stderr+                        $ Console.putTextLn+                        $ "Failed to load session: " <> err+                    exitFailure+                Right session ->+                    Input.withConst session+                        . Input.withConst session.buildSession.commandTemplate.repl+                        . GhcPkg.runGhcPkgIO+                        . PackageStore.run+                        . Hackage.run+                        . DaemonInfo.runInput+                        $ do+                            installTerminationHandler+                            App.run
src/Tricorder/CLI/Operations.hs view
@@ -8,20 +8,26 @@     ) where -import Atelier.Effects.Cache (Cache) import Atelier.Effects.Clock (Clock, currentTimeZone) import Atelier.Effects.Console (Console) import Atelier.Effects.Exit (Exit, exitFailure) import Atelier.Effects.File (File) import Atelier.Effects.FileSystem (FileSystem, doesFileExist, readFileLbs)-import Atelier.Effects.Input (Input)+import Atelier.Effects.Input (Input, input) import Atelier.Effects.Log (Log) import Data.Aeson (ToJSON, encode) import Data.Time.Format (defaultTimeLocale, formatTime) import Data.Time.LocalTime (utcToLocalTime) import Effectful.Reader.Static (Reader, ask)+import Tricorder.CLI.Command+    ( EvalCommentsOptions (..)+    , SourceOptions (..)+    , StatusOptions (..)+    , TestOptions (..)+    , Verbosity (..)+    ) import Tricorder.CLI.Command.OutputFormat (OutputFormat (..))-import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery)+import Tricorder.CLI.Command.WaitMode (WaitMode (..))  import Atelier.Effects.Console qualified as Console import Data.ByteString.Lazy qualified as BSL@@ -31,13 +37,6 @@ import Tricorder.Build (BuildState (..), Severity (..)) import Tricorder.Build.Duration (Duration (..)) import Tricorder.Build.Test (Suites (..))-import Tricorder.CLI.Arguments-    ( EvalCommentsOptions (..)-    , StatusOptions (..)-    , TestOptions (..)-    , Verbosity (..)-    , WaitMode (..)-    ) import Tricorder.CLI.Render     ( diagnosticLineIndexed     , formatDuration@@ -45,13 +44,18 @@     ) import Tricorder.Daemon.DaemonInfo (DaemonInfo (..)) import Tricorder.Runtime (SocketPath (..))+import Tricorder.Session.CabalFile (CabalFile) import Tricorder.Session.Repl (Repl) import Tricorder.Socket.Client (queryStatus, queryStatusWait) import Tricorder.Socket.UnixSocket (UnixSocket)-import Tricorder.SourceLookup (ModuleSourceResult, lookupModuleSource)+import Tricorder.SourceLookup+    ( ReexportLimits (..)+    , defaultReexportLimits+    , lookupModuleSource+    , projectSourceDirs+    ) import Tricorder.SourceLookup.GhcPkg (GhcPkg) import Tricorder.SourceLookup.Hackage (Hackage)-import Tricorder.SourceLookup.PackageId (PackageId) import Tricorder.SourceLookup.PackageStore (PackageStore) import Tricorder.TestOutput (stripGhciNoise) @@ -262,21 +266,27 @@   showSource-    :: ( Cache (PackageId, SourceQuery) ModuleSourceResult :> es-       , Cache ModuleName PackageId :> es-       , Console :> es+    :: ( Console :> es        , FileSystem :> es        , GhcPkg :> es        , Hackage :> es        , Input Repl :> es+       , Input [CabalFile] :> es        , Log :> es        , PackageStore :> es        )-    => [SourceQuery]+    => SourceOptions     -> Eff es ()-showSource queries = do-    results <- mapM lookupModuleSource queries+showSource opts = do+    dirs <- projectSourceDirs <$> input+    results <- mapM (lookupModuleSource dirs limits) opts.queries     renderSourceResults results+  where+    limits =+        ReexportLimits+            { maxDepth = fromMaybe defaultReexportLimits.maxDepth opts.maxDepth+            , maxModules = fromMaybe defaultReexportLimits.maxModules opts.maxModules+            }   showEvalComments
src/Tricorder/CLI/Render.hs view
@@ -59,11 +59,16 @@         when (length results > 1) $ Console.putTextLn $ header query         Console.putText src         when (length results > 1) $ Console.putStrLn ""+    renderOne (SourceReexported query definedIn src) = do+        when (length results > 1) $ Console.putTextLn $ header query+        Console.putTextLn $ "-- Re-exported from " <> unModuleName definedIn+        Console.putText src+        when (length results > 1) $ Console.putStrLn ""     renderOne (SourceNotFound query) =         Console.putTextLn             $ "Not found: "                 <> unModuleName query.moduleName-                <> " (module not in any installed package)"+                <> " (module not in the project or any installed package)"     renderOne (SourceUnavailable query pkgId) =         Console.putTextLn             $ "No source available: "
src/Tricorder/CLI/UI.hs view
@@ -7,11 +7,12 @@ import Atelier.Effects.Conc (Conc) import Atelier.Effects.Console (Console) import Atelier.Effects.Delay (Delay)+import Atelier.Effects.Exit (Exit) import Atelier.Effects.File (File) import Atelier.Effects.Process (Process, getExecutablePath, proc, runProcess)-import Brick (App (..), neverShowCursor)+import Brick (App (..), EventM, neverShowCursor) import Brick.BChan (BChan, writeBChanNonBlocking)-import Brick.Keybindings (KeyConfig)+import Brick.Keybindings (KeyConfig, KeyDispatcher) import Effectful.Concurrent (Concurrent) import Effectful.Concurrent.STM (TVar, atomically, newTVarIO, readTVarIO, writeTVar) import Effectful.Exception (bracket_, trySync)@@ -23,7 +24,7 @@ import Tricorder.CLI.UI.Brick (Brick) import Tricorder.CLI.UI.BrickChan (BrickChan) import Tricorder.CLI.UI.Event (Event (..), handleEvent)-import Tricorder.CLI.UI.Keys (KeyEvent, dispatcher)+import Tricorder.CLI.UI.Keys (KeyEvent, mkDispatcher) import Tricorder.CLI.UI.State (State (..), Viewports (..)) import Tricorder.CLI.UI.View (mkAttrMap, view) import Tricorder.Runtime (SocketPath (..))@@ -47,6 +48,7 @@        , Concurrent :> es        , Console :> es        , Delay :> es+       , Exit :> es        , File :> es        , Process :> es        , Reader Keys.Config :> es@@ -71,10 +73,11 @@         _ <- Conc.fork $ restartWorker restartChan restarting         keyConfig <- Keys.mkKeyConfig         let requestRestart = void $ writeBChanNonBlocking restartChan ()+        dispatcher <- mkDispatcher requestRestart keyConfig         void             $ Brick.runBrickApp                 chan-                (watchApp requestRestart keyConfig)+                (watchApp keyConfig dispatcher)                 initialState  @@ -109,12 +112,15 @@             void waitForDaemon  -watchApp :: IO () -> KeyConfig KeyEvent -> App State Event Viewports-watchApp requestRestart kc =+watchApp+    :: KeyConfig KeyEvent+    -> KeyDispatcher KeyEvent (EventM Viewports State)+    -> App State Event Viewports+watchApp keyConfig dispatcher =     App-        { appDraw = view kc-        , appHandleEvent = handleEvent $ dispatcher requestRestart kc+        { appDraw = view keyConfig dispatcher+        , appChooseCursor = neverShowCursor+        , appHandleEvent = handleEvent dispatcher         , appStartEvent = pure ()         , appAttrMap = mkAttrMap-        , appChooseCursor = neverShowCursor         }
src/Tricorder/CLI/UI/Keys.hs view
@@ -1,15 +1,15 @@ module Tricorder.CLI.UI.Keys-    ( KeyEvent+    ( KeyEvent (..)     , Config     , keys-    , dispatcher+    , mkDispatcher     , viewKeybindings     , mkKeyConfig-    , keybindForRoute     ) where  import Atelier.Effects.Console (Console)+import Atelier.Effects.Exit (Exit, exitFailure) import Brick     ( EventM     , Widget@@ -39,16 +39,13 @@     , onEvent     , parseBindingList     )-import Brick.Keybindings.KeyConfig (firstActiveBinding) import Brick.Keybindings.Pretty (ppBinding) import Brick.Widgets.Core (hBox) import Control.Monad.State (gets, modify) import Data.Aeson (FromJSON (..)) import Data.Default (Default (..))-import Effectful.Exception (throwIO) import Effectful.Reader.Static (Reader, ask) import Graphics.Vty (Key (..))-import System.IO.Error (userError) import Text.Casing (quietSnake)  import Atelier.Effects.Console qualified as Console@@ -57,14 +54,12 @@ import Data.Text qualified as T  import Tricorder.CLI.UI.Misc (warn)-import Tricorder.CLI.UI.Route (Route) import Tricorder.CLI.UI.State     ( Processed (Waiting)     , State (..)     , Viewports (..)     , currentRoute     , cycleTestFilter-    , navigate     , viewToViewport     ) @@ -77,11 +72,11 @@ -- 'KeyEvent', update that list to match — @tagref check@ flags the dangling -- reference if this tag is renamed or dropped without touching the docs. data KeyEvent-    = ToggleHelp+    = SwitchTabPrev+    | SwitchTabNext+    | ToggleHelp     | CycleTestView-    | ToggleEvalComments     | RestartDaemon-    | ExitView     | ScrollUp     | ScrollDown     | Quit@@ -100,37 +95,42 @@ textToKeyEvent = (`Map.lookup` keyEventTextMap)  +eventToDesc :: KeyEvent -> Text+eventToDesc = \case+    ToggleHelp -> "toggle help"+    SwitchTabNext -> "switch to next tab"+    SwitchTabPrev -> "switch to previous tab"+    CycleTestView -> "cycle test tab views"+    RestartDaemon -> "restart daemon"+    ScrollUp -> "scroll up"+    ScrollDown -> "scroll down"+    Quit -> "quit"++ keys :: KeyEvents KeyEvent-keys =-    keyEvents-        [ ("toggle help", ToggleHelp)-        , ("cycle test view", CycleTestView)-        , ("toggle eval comments", ToggleEvalComments)-        , ("restart daemon", RestartDaemon)-        , ("exit view", ExitView)-        , ("scroll up", ScrollUp)-        , ("scroll down", ScrollDown)-        , ("quit", Quit)-        ]+keys = keyEvents $ (\e -> (eventToDesc e, e)) <$> universe  -bindings :: [(KeyEvent, [Binding])]-bindings =-    [ (ToggleHelp, [bind 'h'])-    , (CycleTestView, [bind 't'])-    , (ToggleEvalComments, [bind 'e'])-    , (RestartDaemon, [bind 'R'])-    , (ExitView, [binding KEsc []])-    , (ScrollUp, [binding KUp []])-    , (ScrollDown, [binding KDown []])-    , (Quit, [bind 'q', ctrl 'c'])-    ]+eventToBinding :: KeyEvent -> [Binding]+eventToBinding = \case+    ToggleHelp -> [bind '?']+    SwitchTabPrev -> [bind 'h', binding KLeft []]+    SwitchTabNext -> [bind 'l', binding KRight []]+    CycleTestView -> [bind 't']+    RestartDaemon -> [bind 'R']+    ScrollUp -> [binding KUp []]+    ScrollDown -> [binding KDown []]+    Quit -> [bind 'q', ctrl 'c', binding KEsc []]  -mkKeyConfig :: (Console :> es, Reader Config :> es) => Eff es (KeyConfig KeyEvent)+defaultBindings :: [(KeyEvent, [Binding])]+defaultBindings = (\e -> (e, eventToBinding e)) <$> universe+++mkKeyConfig :: (Console :> es, Exit :> es, Reader Config :> es) => Eff es (KeyConfig KeyEvent) mkKeyConfig = do     customBindings <- parseCustomBindings-    pure $ newKeyConfig keys bindings customBindings+    pure $ newKeyConfig keys defaultBindings customBindings   newtype Config = Config (Map Text Text)@@ -144,6 +144,7 @@  parseCustomBindings     :: ( Console :> es+       , Exit :> es        , Reader Config :> es        )     => Eff es [(KeyEvent, BindingState)]@@ -153,7 +154,7 @@     unless (null errors) do         Console.putTextLn "Error(s) encountered when attempting to parse key bindings:"         traverse_ (Console.putTextLn . toText) errors-        throwIO $ userError "Malformed keybindings"+        exitFailure     pure customBindings  @@ -169,62 +170,75 @@ parseKeyEvent ev = maybeToRight ("Unrecognized key event: " <> ev) $ textToKeyEvent ev  +eventToHandler :: IO () -> KeyEvent -> KeyEventHandler KeyEvent (EventM Viewports State)+eventToHandler requestRestart = \case+    ToggleHelp -> onEvent ToggleHelp "Toggle help" do+        modify \s -> s {showHelp = not s.showHelp}+    SwitchTabNext -> onEvent SwitchTabNext "Switch to next tab" do+        modify \s ->+            s+                { route =+                    if s.route == maxBound+                        then minBound+                        else succ s.route+                }+    SwitchTabPrev -> onEvent SwitchTabPrev "Switch to previous tab" do+        modify \s ->+            s+                { route =+                    if s.route == minBound+                        then maxBound+                        else pred s.route+                }+    CycleTestView -> onEvent CycleTestView "Cycle between test tab views" do+        modify \s ->+            if+                | s.route /= Route.Tests -> s+                | otherwise -> s {testFilter = cycleTestFilter s.testFilter}+    RestartDaemon -> onEvent RestartDaemon "Restart the daemon" do+        liftIO requestRestart+        modify \s -> s {buildState = Waiting}+    ScrollUp -> onEvent ScrollUp "Scroll up" do+        mvp <- gets (viewToViewport . currentRoute)+        case mvp of+            Just vp -> vScrollBy (viewportScroll vp) (-1)+            Nothing -> pure ()+    ScrollDown -> onEvent ScrollDown "Scroll down" do+        mvp <- gets (viewToViewport . currentRoute)+        case mvp of+            Just vp ->+                vScrollBy (viewportScroll vp) 1+            Nothing -> pure ()+    Quit -> onEvent Quit "Exit" do+        halt++ -- | Build the key dispatcher. @requestRestart@ is run (in 'IO') when the restart -- key is pressed; it hands the request off to the worker that owns the daemon -- control effects, since brick's 'EventM' cannot run them directly.-dispatcher :: IO () -> KeyConfig KeyEvent -> KeyDispatcher KeyEvent (EventM Viewports State)-dispatcher requestRestart cfg =-    -- TODO: Handle this error more gracefully.-    either (error . ("Invalid key dispatcher config: " <>) . stringify) id-        $ keyDispatcher-            cfg-            [ onEvent ToggleHelp "Toggle help" do-                modify \s ->-                    if currentRoute s == Route.Help-                        then-                            navigate Route.Main s-                        else-                            navigate Route.Help s-            , onEvent CycleTestView "Cycle test results view" do-                modify \s -> case currentRoute s of-                    Route.Tests ->-                        if s.testFilter == maxBound-                            then-                                navigate Route.Main s {testFilter = minBound}-                            else-                                s {testFilter = cycleTestFilter s.testFilter}-                    _ -> navigate Route.Tests s-            , onEvent ToggleEvalComments "Toggle eval comments view" do-                modify \s ->-                    if currentRoute s == Route.Evals-                        then-                            navigate Route.Main s-                        else-                            navigate Route.Evals s-            , onEvent RestartDaemon "Restart the daemon" do-                liftIO requestRestart-                modify \s -> s {buildState = Waiting}-            , onEvent ExitView "Exit or go back" do-                gets (.route) >>= \case-                    Route.Main -> halt-                    _ -> modify $ navigate Route.Main-            , onEvent ScrollUp "Scroll up" do-                mvp <- gets (viewToViewport . currentRoute)-                case mvp of-                    Just vp -> vScrollBy (viewportScroll vp) (-1)-                    Nothing -> pure ()-            , onEvent ScrollDown "Scroll down" do-                mvp <- gets (viewToViewport . currentRoute)-                case mvp of-                    Just vp ->-                        vScrollBy (viewportScroll vp) 1-                    Nothing -> pure ()-            , onEvent Quit "Exit" do-                halt-            ]+mkDispatcher+    :: (Console :> es, Exit :> es)+    => IO ()+    -> KeyConfig KeyEvent+    -> Eff es (KeyDispatcher KeyEvent (EventM Viewports State))+mkDispatcher requestRestart cfg =+    case keyDispatcher cfg $ eventToHandler requestRestart <$> universe of+        Left collisions -> do+            Console.putTextLn+                $ T.intercalate "\n\n"+                $ "Your key bindings have collisions:"+                    : (uncurry showCollision <$> collisions)+            exitFailure+        Right dispatcher -> pure dispatcher   where-    stringify =-        show . fmap (second $ fmap $ handlerDescription . kehHandler . khHandler)+    showCollision binding' handlers =+        T.intercalate "\n"+            $ ("Key binding: " <> ppBinding binding')+                : (showCollidingHandler <$> handlers)+    showCollidingHandler handler =+        "  " <> case handler.khHandler.kehEventTrigger of+            ByEvent ev -> toText $ quietSnake $ show ev+            ByKey k -> show k   viewKeybindings :: (Ord k, Show k) => KeyConfig k -> [KeyEventHandler k m] -> Widget n@@ -248,11 +262,3 @@     getBindings = \case         ByKey k -> Set.singleton k         ByEvent e -> Set.fromList $ allActiveBindings kc e---keybindForRoute :: KeyConfig KeyEvent -> Route -> Maybe Binding-keybindForRoute kc = \case-    Route.Main -> Nothing-    Route.Help -> firstActiveBinding kc ToggleHelp-    Route.Tests -> firstActiveBinding kc CycleTestView-    Route.Evals -> firstActiveBinding kc ToggleEvalComments
src/Tricorder/CLI/UI/Route.hs view
@@ -7,7 +7,6 @@  data Route     = Main-    | Help     | Tests     | Evals     deriving stock (Bounded, Enum, Eq)@@ -16,6 +15,5 @@ name :: Route -> Text name = \case     Main -> "Dashboard"-    Help -> "Help"     Tests -> "Tests"     Evals -> "Eval comments"
src/Tricorder/CLI/UI/State.hs view
@@ -35,6 +35,7 @@     , timeZone :: TimeZone     , route :: Route     , testFilter :: TestFilter+    , showHelp :: Bool     }  @@ -76,4 +77,5 @@             , timeZone = tz             , route = Route.Main             , testFilter = minBound+            , showHelp = False             }
src/Tricorder/CLI/UI/View.hs view
@@ -12,7 +12,7 @@     , vBox     , viewport     )-import Brick.Keybindings (KeyConfig, KeyHandler (..), keyDispatcherToList, ppBinding)+import Brick.Keybindings (KeyConfig, KeyDispatcher, KeyHandler (..), keyDispatcherToList, ppBinding) import Brick.Widgets.Core     ( Padding (..)     , emptyWidget@@ -27,6 +27,7 @@     ) import Data.Time (UTCTime, defaultTimeLocale, formatTime, utcToLocalTime) +import Brick.Keybindings.KeyConfig qualified as KeyConfig import Data.Map.Strict qualified as Map import Data.Text qualified as T import Graphics.Vty.Attributes qualified as Attr@@ -34,7 +35,7 @@  import Tricorder.Build (BuildPhase, BuildResult, BuildState, Diagnostic, Severity (..)) import Tricorder.Build.Duration (Duration (..))-import Tricorder.CLI.UI.Keys (KeyEvent, keybindForRoute, viewKeybindings)+import Tricorder.CLI.UI.Keys (KeyEvent, viewKeybindings) import Tricorder.CLI.UI.Misc (emphasis, err, hBoxSpaced, ok, subtle, vBoxSpaced, warn) import Tricorder.CLI.UI.Route (Route) import Tricorder.CLI.UI.State@@ -69,41 +70,35 @@         )  -view :: KeyConfig KeyEvent -> State -> [Widget Viewports]-view kc ws =+view :: KeyConfig KeyEvent -> KeyDispatcher KeyEvent m -> State -> [Widget Viewports]+view kc dispatcher ws =     [ vBoxSpaced         1         [ vBox-            [ viewAppHeader ws-            , viewTabs kc ws+            [ viewAppHeader kc ws+            , viewTabs ws             ]-        , case currentRoute ws of-            Route.Help ->-                viewHelp kc-            Route.Tests ->-                viewTests ws-            Route.Main ->-                viewMain ws-            Route.Evals ->-                viewEvals ws+        , if ws.showHelp+            then viewHelp kc dispatcher+            else case currentRoute ws of+                Route.Tests -> viewTests ws+                Route.Main -> viewMain ws+                Route.Evals -> viewEvals ws         ]     ]  -viewTabs :: KeyConfig KeyEvent -> State -> Widget n-viewTabs kc ws =-    hBoxSpaced 1-        $ intersperse (subtle $ txt "-")-        $ viewRouteTab kc ws <$> universe @Route+viewTabs :: State -> Widget n+viewTabs ws =+    hBoxSpaced 1 $ viewRouteTab ws <$> universe @Route  -viewRouteTab :: KeyConfig KeyEvent -> State -> Route -> Widget n-viewRouteTab kc ws route =-    style $ txt $ Route.name route <> keyBind+viewRouteTab :: State -> Route -> Widget n+viewRouteTab ws route =+    style $ txt $ brackets $ Route.name route   where+    brackets = if route == currentRoute ws then ("< " <>) . (<> " >") else ("  " <>) . (<> "  ")     style = if route == currentRoute ws then id else subtle-    showBinding = (" " <>) . ("[" <>) . (<> "]") . ppBinding-    keyBind = maybe "" showBinding $ keybindForRoute kc route   viewTests :: State -> Widget Viewports@@ -118,12 +113,12 @@ viewMain ws = withBuildState ws (viewDefaultPanel ws.timeZone)  -viewHelp :: KeyConfig KeyEvent -> Widget n-viewHelp kc = viewKeybindings kc handlers+viewHelp :: KeyConfig KeyEvent -> KeyDispatcher KeyEvent m -> Widget n+viewHelp kc dispatcher = viewKeybindings kc handlers   where     -- The dispatcher is built only to enumerate handler descriptions for the help     -- view, so the restart action is a no-op here.-    handlers = (.khHandler) . snd <$> keyDispatcherToList (Keys.dispatcher (pure ()) kc)+    handlers = (.khHandler) . snd <$> keyDispatcherToList dispatcher   withBuildState :: State -> (BuildState -> Widget Viewports) -> Widget Viewports@@ -137,26 +132,49 @@             render bs  -viewAppHeader :: State -> Widget n-viewAppHeader ws =-    ok-        $ emphasis-        $ txt-        $ "Tricorder"-            <> maybe-                ""-                (" - " <>)-                (viewHeading ws)+viewAppHeader :: KeyConfig KeyEvent -> State -> Widget n+viewAppHeader kc ws =+    hBoxSpaced+        1+        [ ok+            $ emphasis+            $ txt+            $ "Tricorder"+                <> maybe+                    ""+                    (" - " <>)+                    (viewHeading ws)+        , viewHelpHint kc ws+        ]  +viewHelpHint :: KeyConfig KeyEvent -> State -> Widget n+viewHelpHint kc state+    | state.showHelp = subtle $ txt $ "Press " <> exitHelpStr <> " to go back"+    | state.route == Route.Tests =+        subtle $ txt $ "Press " <> cycleTestFilterStr <> " to cycle between filters"+    | otherwise = subtle $ txt $ "Press " <> showHelpStr <> " for help"+  where+    exitHelpStr = showHint Keys.ToggleHelp+    showHelpStr = showHint Keys.ToggleHelp+    cycleTestFilterStr = showHint Keys.CycleTestView+    showHint = joinWithAndOr "or" . fmap (quote . ppBinding) . KeyConfig.allActiveBindings kc+    quote = (<> "'") . ("'" <>)+    joinWithAndOr andOr xs = case splitAt (length xs - 1) xs of+        ([], [post]) -> post+        ([pre], []) -> pre+        (pre, post) -> T.intercalate ", " pre <> " " <> andOr <> " " <> T.intercalate ", " post++ viewHeading :: State -> Maybe Text-viewHeading ws = case currentRoute ws of-    Route.Tests -> case ws.testFilter of-        TestFilterAll -> Just "Tests"-        TestFilterFailedOnly -> Just "Tests - Failed only"-    Route.Help -> Just "Help"-    Route.Main -> Nothing-    Route.Evals -> Just "Eval comments"+viewHeading ws+    | ws.showHelp = Just "Help"+    | otherwise = case currentRoute ws of+        Route.Tests -> case ws.testFilter of+            TestFilterAll -> Just "Tests"+            TestFilterFailedOnly -> Just "Tests - Failed only"+        Route.Main -> Nothing+        Route.Evals -> Just "Eval comments"   viewDefaultPanel :: TimeZone -> BuildState -> Widget Viewports
src/Tricorder/Config.hs view
@@ -1,62 +1,44 @@ module Tricorder.Config-    ( LoadedConfig (..)-    , runLoadedConfig-    , inputLoadedConfig-    , configFileName+    ( Config (..)+    , asInput     ) where -import Atelier.Config (LoadedConfig (..))-import Atelier.Effects.FileSystem (FileSystem)-import Atelier.Effects.Input (Input, runInputEff)-import Effectful.Reader.Static (Reader, ask, runReader)-import System.FilePath ((</>))+import Atelier.Effects.Input (Input)+import Atelier.Types.JSON.QuietSnake (QuietSnake (..))+import Atelier.Types.WithDefaults (WithDefaults (..))+import Data.Aeson (FromJSON, ToJSON)+import Data.Default (Default (..)) -import Atelier.Effects.FileSystem qualified as FileSystem+import Atelier.Effects.Input qualified as Input import Data.Aeson qualified as Aeson-import Data.Aeson.KeyMap qualified as KM import Data.Yaml qualified as Yaml -import Tricorder.Runtime (ProjectRoot (..))-+import Tricorder.Config.LoadedConfig (LoadedConfig (..)) --- | Load config from .tricorder.yaml in the project root.--- Falls back to empty config (all defaults) if the file is absent or cannot be parsed.-loadTricorderConfig :: (FileSystem :> es) => FilePath -> Eff es LoadedConfig-loadTricorderConfig projectRoot = do-    exists <- FileSystem.doesFileExist yamlPath-    if not exists-        then-            pure $ LoadedConfig (Aeson.Object KM.empty)-        else do-            bs <- FileSystem.readFileBs yamlPath-            pure . LoadedConfig $ case Yaml.decodeEither' @Aeson.Value bs of-                Left _ -> Aeson.Object KM.empty-                Right v -> v-  where-    yamlPath = projectRoot </> configFileName+import Tricorder.Session.Config qualified as Session (Config)  -configFileName :: FilePath-configFileName = ".tricorder.yaml"+data Config = Config+    { session :: Maybe Session.Config+    }+    deriving stock (Eq, Generic, Show)+    deriving (ToJSON) via QuietSnake Config+    deriving (FromJSON) via WithDefaults (QuietSnake Config)  -runLoadedConfig-    :: ( FileSystem :> es-       , Reader ProjectRoot :> es-       )-    => Eff (Reader LoadedConfig : es) a -> Eff es a-runLoadedConfig act = do-    ProjectRoot projectRoot <- ask-    cfg <- loadTricorderConfig projectRoot-    runReader cfg act+instance Default Config where+    def =+        Config+            { session = def+            }  -inputLoadedConfig-    :: ( FileSystem :> es-       , Reader ProjectRoot :> es-       )-    => Eff (Input LoadedConfig : es) a -> Eff es a-inputLoadedConfig = runInputEff do-    ProjectRoot projectRoot <- ask-    loadTricorderConfig projectRoot+asInput+    :: (Input LoadedConfig :> es)+    => Eff (Input (Either Text Config) : es) a -> Eff es a+asInput = Input.withEff do+    LoadedConfig bs <- Input.input+    case Yaml.decodeEither' bs of+        Right Aeson.Null -> pure $ Right def+        _ -> pure $ first (toText . Yaml.prettyPrintParseException) $ Yaml.decodeEither' bs
+ src/Tricorder/Config/LoadedConfig.hs view
@@ -0,0 +1,92 @@+{-# LANGUAGE AllowAmbiguousTypes #-}++module Tricorder.Config.LoadedConfig+    ( LoadedConfig (..)+    , asInput+    , asNestedInput+    , decodeNestedValue+    , configFileName+    )+where++import Atelier.Effects.FileSystem (FileSystem)+import Atelier.Effects.Input (Input)+import Data.Aeson (FromJSON)+import Effectful.Reader.Static (Reader)+import GHC.TypeLits (KnownSymbol, Symbol, symbolVal)+import System.FilePath ((</>))++import Atelier.Effects.FileSystem qualified as FileSystem+import Atelier.Effects.Input qualified as Input+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KM+import Data.List qualified as List+import Data.Text qualified as T+import Data.Yaml qualified as Yaml+import Effectful.Reader.Static qualified as Reader++import Tricorder.Runtime (ProjectRoot (..))+++newtype LoadedConfig = LoadedConfig {getLoadedConfig :: ByteString}+++asInput+    :: (FileSystem :> es, Reader ProjectRoot :> es)+    => Eff (Input LoadedConfig : es) a -> Eff es a+asInput = Input.withEff do+    ProjectRoot projectRoot <- Reader.ask+    let configFilePath = projectRoot </> configFileName+    exists <- FileSystem.doesFileExist configFilePath+    if exists+        then do+            bs <- FileSystem.readFileBs configFilePath+            pure $ LoadedConfig bs+        else pure $ LoadedConfig ""+++asNestedInput+    :: forall (key :: Symbol) r es a+     . ( FromJSON r+       , Input LoadedConfig :> es+       , KnownSymbol key+       )+    => Eff (Input (Either Text r) : es) a -> Eff es a+asNestedInput = Input.withEff do+    loadedConfig <- Input.input+    pure $ decodeNestedValue @key loadedConfig+++configFileName :: FilePath+configFileName = ".tricorder.yaml"+++decodeNestedValue+    :: forall (key :: Symbol) r. (FromJSON r, KnownSymbol key) => LoadedConfig -> Either Text r+decodeNestedValue (LoadedConfig root) =+    go [] props+        =<< first+            (toText . Yaml.prettyPrintParseException)+            (Yaml.decodeEither' root)+  where+    props = T.splitOn "." $ toText $ symbolVal $ Proxy @key++    go :: [Text] -> [Text] -> Aeson.Value -> Either Text r+    go prev (p : ps) (Aeson.Object m) =+        maybe+            (Left $ "missing property '" <> p <> "' at " <> formatPrev prev)+            (go (p : prev) ps)+            $ KM.lookup (fromString $ toString p) m+    go prev (_ : _) _ =+        Left $ "expected property to be an object: " <> formatPrev prev+    go prev [] x =+        first (\e -> "parse error for property " <> formatPrev prev <> ": " <> e)+            $ fromJSON x++    fromJSON x = case Aeson.fromJSON x of+        Aeson.Success a -> Right a+        Aeson.Error err -> Left $ toText err++    formatPrev prev+        | List.null prev = "<root object>"+        | otherwise = T.intercalate "." $ List.reverse prev
src/Tricorder/Daemon/Builder.hs view
@@ -13,7 +13,7 @@ import Atelier.Effects.Clock (Clock, UTCTime) import Atelier.Effects.FileWatcher (FileEvent) import Atelier.Effects.Log (Log)-import Atelier.Effects.Publishing.Pub (Pub)+import Atelier.Effects.PubSub.Pub (Pub) import Atelier.Time (nominalDiffTime) import Data.Time (diffUTCTime) import Effectful (Effect, inject)@@ -26,7 +26,7 @@  import Atelier.Effects.Clock qualified as Clock import Atelier.Effects.Log qualified as Log-import Atelier.Effects.Publishing.Pub qualified as Pub+import Atelier.Effects.PubSub.Pub qualified as Pub import Data.Map.Strict qualified as Map import Data.Set qualified as Set import Effectful.State.Static.Shared qualified as State@@ -44,13 +44,16 @@     , mergeDiagnostics     , preserveFailureVisibility     )-import Tricorder.Daemon.GhciSession (GhciSession, LoadResult (..))-import Tricorder.Daemon.GhciSession.GhciParser (resolveKnownTargets) import Tricorder.Runtime (ProjectRoot (..)) import Tricorder.Session.Command.RenderedCommand (RenderedCommand) import Tricorder.Session.WatchDirs (WatchDirs) -import Tricorder.Daemon.GhciSession qualified as GhciSession+import Tricorder.Daemon.Ghci.Controls qualified as Controls+import Tricorder.Daemon.Ghci.Controls qualified as Ghci (Controls)+import Tricorder.Daemon.Ghci.LoadResult qualified as Ghci (LoadResult)+import Tricorder.Daemon.Ghci.LoadResult qualified as Ghci.LoadResult+import Tricorder.Daemon.Ghci.Session qualified as Ghci (Session)+import Tricorder.Daemon.Ghci.Session qualified as Ghci.Session import Tricorder.Session.Stage qualified as Stage  @@ -72,7 +75,7 @@ data NewLoadResult = NewLoadResult     { startTime :: UTCTime     , endTime :: UTCTime-    , loadResult :: LoadResult+    , loadResult :: Ghci.LoadResult     }     deriving stock (Eq, Show) @@ -82,7 +85,7 @@  with     :: ( Clock :> es-       , GhciSession :> es+       , Ghci.Session :> es        , Log :> es        , Pub BuildProgress :> es        , Reader ProjectRoot :> es@@ -95,7 +98,7 @@ with buildId command watchDirs action = do     root <- ask     startTime <- Clock.currentTime-    trySync $ GhciSession.withGhciWith Pub.publish command root \initialLoad controls -> do+    trySync $ Ghci.Session.with Pub.publish command root \initialLoad controls -> do         endTime <- Clock.currentTime         let filteredMsgs = filterToWatchDirs root.getProjectRoot watchDirs initialLoad.diagnostics         Log.info@@ -109,7 +112,7 @@         let initialLoadResult = NewLoadResult {startTime, endTime, loadResult = initialLoad}         let initialBuilderState =                 emptyBuilderState-                    { loadedModules = resolveKnownTargets Map.empty initialLoad+                    { loadedModules = Ghci.LoadResult.resolveKnownTargets Map.empty initialLoad                     , knownTargets = KnownTargetNames (Set.fromList initialLoad.targetNames)                     }         reinterpretWith_@@ -118,7 +121,7 @@             \case                 Consider fp event -> considerBuild fp event                 Build dispatchAction ->-                    buildSource (GhciSession.transformControls inject controls) dispatchAction+                    buildSource (Controls.lift inject controls) dispatchAction   considerBuild@@ -139,7 +142,7 @@  buildSource     :: (Clock :> es, State BuilderState :> es)-    => GhciSession.Controls (Eff es)+    => Ghci.Controls (Eff es)     -> DispatchAction     -> Eff es (Either BuildFailure NewLoadResult) buildSource controls action = do@@ -155,7 +158,7 @@         Right (startTime, endTime, loadResult) -> do             modify \s ->                 s-                    { loadedModules = resolveKnownTargets s.loadedModules loadResult+                    { loadedModules = Ghci.LoadResult.resolveKnownTargets s.loadedModules loadResult                     , knownTargets = KnownTargetNames (Set.fromList loadResult.targetNames)                     }             pure $ Right $ NewLoadResult {startTime, endTime, loadResult}@@ -174,7 +177,7 @@     merged = mergeDiagnostics diagnosticMap filteredResult     filteredResult =         loadResult-            { GhciSession.diagnostics =+            { Ghci.LoadResult.diagnostics =                 preserveFailureVisibility loadResult.diagnostics                     $ filterToWatchDirs projectRoot watchDirs loadResult.diagnostics             }@@ -187,7 +190,7 @@             }  -runAction :: GhciSession.Controls (Eff es) -> DispatchAction -> Eff es LoadResult+runAction :: Ghci.Controls (Eff es) -> DispatchAction -> Eff es Ghci.LoadResult runAction controls = \case     Reload -> controls.reload     Add fp -> controls.add fp
src/Tricorder/Daemon/Core.hs view
@@ -3,29 +3,22 @@ import Atelier.Effects.Chan (Chan) import Atelier.Effects.Clock (Clock) import Atelier.Effects.Conc (Conc)-import Atelier.Effects.Debounce (Debounce)-import Atelier.Effects.FileSystem (FileSystem)-import Atelier.Effects.FileWatcher (FileEvent, FileWatcher)-import Atelier.Effects.Input (Input, input)+import Atelier.Effects.FileWatcher (FileEvent) import Atelier.Effects.Log (Log) import Atelier.Effects.Process (Process)-import Atelier.Effects.Publishing (runPubSub)-import Atelier.Effects.Publishing.Pub (Pub)-import Atelier.Effects.Publishing.Sub (Sub)-import Data.List (isSuffixOf)+import Atelier.Effects.PubSub.Pub (Pub)+import Atelier.Effects.PubSub.Sub (Sub) import Effectful.Concurrent.MVar (newEmptyMVar, takeMVar, tryPutMVar) import Effectful.Concurrent.STM (Concurrent, atomically, newEmptyTMVar, takeTMVar, writeTMVar) import Effectful.Reader.Static (Reader) import Effectful.State.Static.Shared (State) import Relude.Extra.Tuple (dup)-import System.FilePath ((</>))  import Atelier.Effects.Conc qualified as Conc-import Atelier.Effects.FileSystem qualified as FileSystem-import Atelier.Effects.FileWatcher qualified as FileWatcher import Atelier.Effects.Log qualified as Log-import Atelier.Effects.Publishing.Pub qualified as Pub-import Atelier.Effects.Publishing.Sub qualified as Sub+import Atelier.Effects.PubSub qualified as PubSub+import Atelier.Effects.PubSub.Pub qualified as Pub+import Atelier.Effects.PubSub.Sub qualified as Sub import Data.Map.Strict qualified as Map import Effectful.Reader.Static qualified as Reader import Effectful.State.Static.Shared qualified as State@@ -42,20 +35,10 @@     ) import Tricorder.Daemon.Dispatch (BuilderState (..), DispatchAction, emptyBuilderState) import Tricorder.Daemon.EvalCommentRunner (EvalCommentRunner, findEvalCommentsInModules)-import Tricorder.Daemon.GhciSession (GhciSession)-import Tricorder.Daemon.GhciSession.GhciParser-    ( LoadResult-    , LoadedModule (..)-    , resolveKnownTargets-    )-import Tricorder.Daemon.Hpack.Effect (Hpack)+import Tricorder.Daemon.Ghci.LoadedModule (LoadedModule (..)) import Tricorder.Daemon.TestRunner (TestRunner)-import Tricorder.Daemon.Watch (WatchedFile) import Tricorder.Runtime (ProjectRoot (..)) import Tricorder.Session (Session (..))-import Tricorder.Session.GenerateWithHpack (GenerateWithHpack (..))-import Tricorder.Session.IdleTimeout (IdleTimeout)-import Tricorder.Session.Repl (Repl) import Tricorder.Session.Stage.Build.Session (BuildSession (..)) import Tricorder.Session.Stage.Test.Command (RenderedTestCommand (..)) import Tricorder.Session.Stage.Test.Session (TestSession (..))@@ -65,14 +48,13 @@ import Tricorder.Build qualified as Build import Tricorder.Build.EvalComment qualified as Eval import Tricorder.Build.Test qualified as Test-import Tricorder.Config qualified as Config import Tricorder.Daemon.Builder qualified as Builder import Tricorder.Daemon.EvalCommentRunner qualified as EvalCommentRunner-import Tricorder.Daemon.Hpack qualified as Hpack+import Tricorder.Daemon.Ghci.LoadResult qualified as Ghci (LoadResult)+import Tricorder.Daemon.Ghci.LoadResult qualified as Ghci.LoadResult+import Tricorder.Daemon.Ghci.Session qualified as Ghci (Session) import Tricorder.Daemon.TestRunner qualified as TestRunner-import Tricorder.Daemon.Watch qualified as Watch-import Tricorder.Session qualified as Session-import Tricorder.Session.CommandTemplate qualified as Command+import Tricorder.Session.Hook qualified as Hook import Tricorder.Session.Hooks qualified as Hooks import Tricorder.Session.Stage.Build.Command qualified as BuildCommand import Tricorder.Session.Stage.Test.Command qualified as TestCommand@@ -80,9 +62,6 @@ import Tricorder.Waiters qualified as Waiters  -data ReloadSession = ReloadSession-- data RestartBuilder = RestartBuilder  @@ -96,138 +75,32 @@        , Clock :> es        , Conc :> es        , Concurrent :> es-       , Debounce FilePath :> es        , EvalCommentRunner :> es-       , FileSystem :> es-       , FileWatcher :> es-       , GhciSession :> es-       , Hpack :> es-       , Input Session :> es+       , Ghci.Session :> es        , Log :> es        , Process :> es        , Pub BuildPhase :> es        , Reader ProjectRoot :> es        , State BuildId :> es-       , State IdleTimeout :> es-       , State Repl :> es-       , TestRunner :> es-       , Waiters :> es-       )-    => Eff es Void-main =-    runPubSub @ReloadSession-        . runPubSub @WatchedFile-        . runPubSub @CabalChangeDetected-        . runPubSub @SourceChangeDetected-        . runPubSub @RestartBuilder-        . runPubSub @ReloadBuilder-        . Conc.restartableFork waitForReloadSession-        $ do-            Log.debug "Before session"-            session <- input-            Log.debug "After session"-            withSession session-  where-    waitForReloadSession = Waiters.wait $ Sub.listenOnce_ @ReloadSession---withSession-    :: ( Chan :> es-       , Clock :> es-       , Conc :> es-       , Concurrent :> es-       , Debounce FilePath :> es-       , EvalCommentRunner :> es-       , FileSystem :> es-       , FileWatcher :> es-       , GhciSession :> es-       , Hpack :> es-       , Input Session :> es-       , Log :> es-       , Process :> es-       , Pub BuildPhase :> es-       , Pub CabalChangeDetected :> es-       , Pub ReloadBuilder :> es-       , Pub ReloadSession :> es-       , Pub RestartBuilder :> es-       , Pub SourceChangeDetected :> es-       , Pub WatchedFile :> es-       , Reader ProjectRoot :> es-       , State BuildId :> es-       , State IdleTimeout :> es-       , State Repl :> es        , Sub CabalChangeDetected :> es-       , Sub ReloadBuilder :> es-       , Sub RestartBuilder :> es        , Sub SourceChangeDetected :> es-       , Sub WatchedFile :> es        , TestRunner :> es        , Waiters :> es        )     => Session -> Eff es Void-withSession session = do-    root <- Reader.ask-    logSession session--    State.put session.buildSession.commandTemplate.repl-    State.put session.idleTimeout-    Conc.fork_ $ watchConfigFile root-    conditionallyWatchStackYaml root--    Conc.fork_ $ Watch.files root session-    Conc.fork_ $ Sub.listen_ Watch.publishChange--    Conc.fork_ $ Sub.listen_ \(CabalChangeDetected _ _) ->-        ifM-            (shouldReloadSession session)-            (Pub.publish ReloadSession)-            (Pub.publish RestartBuilder)--    Conc.fork_ $ Sub.listen_ \(SourceChangeDetected fp event) ->-        Pub.publish $ ReloadBuilder fp event--    when (coerce session.generateWithHpack)-        $ void-        $ Conc.fork Hpack.main--    State.evalState emptyBuilderState do-        Conc.restartableFork (Waiters.wait $ Sub.listenOnce_ @RestartBuilder) do+main session =+    PubSub.run @ReloadBuilder+        . PubSub.run @RestartBuilder+        $ Conc.restartableFork (Waiters.wait $ Sub.listenOnce_ @RestartBuilder) do             buildId <- State.state (\s -> (s, s + 1))-            runBuilder buildId session --shouldReloadSession :: (Input Session :> es) => Session -> Eff es Bool-shouldReloadSession oldSession = do-    newSession <- input-    pure $ newSession /= oldSession---watchConfigFile-    :: ( Debounce FilePath :> es-       , FileWatcher :> es-       , Pub ReloadSession :> es-       )-    => ProjectRoot -> Eff es Void-watchConfigFile root = do-    FileWatcher.watchFilePathsDebounced-        [FileWatcher.dirWhere root.getProjectRoot (Config.configFileName `isSuffixOf`)]-        \_ _ -> Pub.publish ReloadSession+            Conc.fork_ $ Sub.listen_ \(CabalChangeDetected _ _) ->+                Pub.publish RestartBuilder +            Conc.fork_ $ Sub.listen_ \(SourceChangeDetected path event) ->+                Pub.publish $ ReloadBuilder path event -conditionallyWatchStackYaml-    :: ( Conc :> es-       , Debounce FilePath :> es-       , FileSystem :> es-       , FileWatcher :> es-       , Pub RestartBuilder :> es-       )-    => ProjectRoot -> Eff es ()-conditionallyWatchStackYaml root = do-    exists <- FileSystem.doesFileExist $ root.getProjectRoot </> "stack.yaml"-    when exists do-        Conc.fork_ $ FileWatcher.watchFilePathsDebounced-            [FileWatcher.dirWhere root.getProjectRoot ("stack.yaml" `isSuffixOf`)]-            \_ _ -> Pub.publish RestartBuilder+            runBuilder buildId session   -- | Starts the initial build with GHCi, and waits for source changes.@@ -236,25 +109,24 @@        , Conc :> es        , Concurrent :> es        , EvalCommentRunner :> es-       , GhciSession :> es+       , Ghci.Session :> es        , Log.Log :> es        , Process :> es        , Pub BuildPhase :> es        , Reader ProjectRoot :> es-       , State BuilderState :> es        , Sub ReloadBuilder :> es        , TestRunner :> es        , Waiters :> es        )     => BuildId -> Session -> Eff es ()-runBuilder buildId session = do+runBuilder buildId session = State.evalState emptyBuilderState do     Log.info $ "Starting session " <> show buildId.getBuildId     Pub.publish Build.Starting-    whenJust (session.hooks.start >>= (.before)) Hooks.runHook+    whenJust (session.hooks.start >>= (.before)) Hook.run     startupError <- fmap (either id absurd)         $ Pub.map (Build.Building session.testSession.targets)         $ Builder.with buildId buildCommand session.watchDirs \_ initialLoad -> do-            whenJust (session.hooks.start >>= (.after)) Hooks.runHook+            whenJust (session.hooks.start >>= (.after)) Hook.run             processPostBuild session $ Right initialLoad             Log.debug "Waiting for reload"             newestReloadEvent <- atomically newEmptyTMVar@@ -272,7 +144,7 @@                 Conc.restartableFork checkCancel                     $ waitForReload session event -    Pub.publish $ Build.Failed $ show startupError+    Pub.publish $ Build.Failed $ toText $ displayException startupError   where     buildCommand = BuildCommand.render session.buildSession.commandTemplate session.buildSession.targets @@ -320,9 +192,9 @@     -> DispatchAction     -> Eff es () processSource session action = do-    whenJust (session.hooks.reload >>= (.before)) Hooks.runHook+    whenJust (session.hooks.reload >>= (.before)) Hook.run     res <- Builder.build action-    whenJust (session.hooks.reload >>= (.after)) Hooks.runHook+    whenJust (session.hooks.reload >>= (.after)) Hook.run     Log.debug "Finished build"     processPostBuild session res @@ -374,11 +246,12 @@        , Pub Eval.Phase :> es        , State BuilderState :> es        )-    => Session -> LoadResult -> Eff es Eval.Phase+    => Session -> Ghci.LoadResult -> Eff es Eval.Phase runEvalComments session loadResult = do     builderState <- State.get @BuilderState     evalComments <--        findEvalCommentsInModules $ resolveKnownTargets builderState.loadedModules loadResult+        findEvalCommentsInModules+            $ Ghci.LoadResult.resolveKnownTargets builderState.loadedModules loadResult      case nonEmpty evalComments of         Nothing -> pure Eval.NoneFound@@ -466,7 +339,3 @@                     s.diagnosticMap                     newLoadResult         in  (buildResult, s {diagnosticMap = newDiagnosticMap})---logSession :: (Log :> es) => Session -> Eff es ()-logSession = Log.info . (<> "\n") . Session.show
src/Tricorder/Daemon/DaemonInfo.hs view
@@ -5,12 +5,14 @@     ) where -import Atelier.Effects.Input (Input, input, runInputEff)+import Atelier.Effects.Input (Input) import Data.Aeson (FromJSON (..), ToJSON (..)) import Effectful.Reader.Static (Reader, ask) import GHC.Generics (Generically (..)) import System.FilePath (makeRelative) +import Atelier.Effects.Input qualified as Input+ import Tricorder.Runtime (LogPath (..), ProjectRoot (..), SocketPath (..)) import Tricorder.Session (Session (..)) import Tricorder.Session.Stage.Build.Session (BuildSession (..))@@ -36,7 +38,7 @@        )     => Eff es DaemonInfo load = do-    session <- input+    session <- Input.input     ProjectRoot projectRoot <- ask     SocketPath sockPath <- ask     LogPath logFile <- ask@@ -56,4 +58,4 @@        , Reader SocketPath :> es        )     => Eff (Input DaemonInfo : es) a -> Eff es a-runInput = runInputEff load+runInput = Input.withEff load
src/Tricorder/Daemon/Dispatch.hs view
@@ -20,22 +20,21 @@ import Data.Set qualified as Set  import Tricorder.Build (Diagnostic (..), Severity (..))-import Tricorder.Daemon.GhciSession.GhciParser-    ( LoadResult (..)-    , LoadedModule (..)-    , isLocationLess-    , pathSuffixesAsModuleName-    , unattributedFailure-    ) import Tricorder.Session.WatchDirs (WatchDirs (..)) +import Tricorder.Daemon.Ghci.Diagnostic qualified as Ghci.Diagnostic+import Tricorder.Daemon.Ghci.LoadResult qualified as Ghci+import Tricorder.Daemon.Ghci.LoadedModule qualified as Ghci (LoadedModule)+import Tricorder.Daemon.Ghci.LoadedModule qualified as Ghci.LoadedModule+import Tricorder.Daemon.Ghci.Path qualified as Ghci.Path + -- | The Builder's per-GHCi-session cache: what it last saw from GHCi plus its -- accumulated diagnostics. Reset on every GHCi restart in -- @buildWithGhciOnChange@; 'BuildId' is intentionally /not/ here because it -- counts across restarts. data BuilderState = BuilderState-    { loadedModules :: Map FilePath LoadedModule+    { loadedModules :: Map FilePath Ghci.LoadedModule     , knownTargets :: KnownTargetNames     , diagnosticMap :: DiagnosticMap     }@@ -66,9 +65,9 @@ -- forever once raised. They describe the current load's outcome, so we clear -- them every cycle and let this cycle's 'diagnostics' re-add them if the -- failure is still present.-mergeDiagnostics :: DiagnosticMap -> LoadResult -> DiagnosticMap-mergeDiagnostics prev LoadResult {compiledFiles, diagnostics} =-    let retained = Map.filterWithKey (\f _ -> not (isLocationLess f)) prev+mergeDiagnostics :: DiagnosticMap -> Ghci.LoadResult -> DiagnosticMap+mergeDiagnostics prev Ghci.LoadResult {compiledFiles, diagnostics} =+    let retained = Map.filterWithKey (\f _ -> not (Ghci.Path.isLocationLess f)) prev         cleared = foldr Map.delete retained compiledFiles         newByFile = Map.fromListWith (++) [(d.file, [d]) | d <- diagnostics]     in  Map.union newByFile cleared@@ -95,7 +94,7 @@ -- leaving the diagnostic stale. fileMatchesAnyTarget :: KnownTargetNames -> FilePath -> Bool fileMatchesAnyTarget (KnownTargetNames targets) fp =-    any (`Set.member` targets) (pathSuffixesAsModuleName fp)+    any (`Set.member` targets) (Ghci.Path.pathSuffixesAsModuleName fp)         || any (pathTargetMatches fp . toString) (Set.toList targets)  @@ -125,7 +124,7 @@ -- stale diagnostics in place. dispatch     :: KnownTargetNames-    -> Maybe LoadedModule+    -> Maybe Ghci.LoadedModule     -> FilePath     -> FileEvent     -> Maybe DispatchAction@@ -160,7 +159,7 @@ filterToWatchDirs :: FilePath -> WatchDirs -> [Diagnostic] -> [Diagnostic] filterToWatchDirs _ (WatchDirs []) diags = diags filterToWatchDirs projectRoot (WatchDirs watchDirs) diags =-    filter (\d -> isLocationLess d.file || isUnderAnyWatchDir d.file) diags+    filter (\d -> Ghci.Path.isLocationLess d.file || isUnderAnyWatchDir d.file) diags   where     absWatchDirs = map toAbsWd watchDirs     toAbsWd wd@@ -197,7 +196,7 @@ -- post-filter list, with the synthetic failure appended only when needed. preserveFailureVisibility :: [Diagnostic] -> [Diagnostic] -> [Diagnostic] preserveFailureVisibility raw filtered-    | any isError raw && not (any isError filtered) = filtered ++ [unattributedFailure]+    | any isError raw && not (any isError filtered) = filtered ++ [Ghci.Diagnostic.unattributedFailure]     | otherwise = filtered   where     isError d = d.severity == SError
src/Tricorder/Daemon/EvalCommentRunner.hs view
@@ -29,12 +29,13 @@ import Data.Map.Strict qualified as Map import Data.Text qualified as T -import Tricorder.Daemon.GhciSession.GhciParser (LoadedModule (..))-import Tricorder.Daemon.GhciSession.GhciProcess (execGhci, withGhciProcess)+import Tricorder.Daemon.Ghci.LoadedModule (LoadedModule (..)) import Tricorder.Runtime (ProjectRoot (..)) import Tricorder.Session.CommandTemplate (CommandTemplate)  import Tricorder.Build.EvalComment qualified as Eval+import Tricorder.Daemon.Ghci.LoadedModule qualified as Ghci (LoadedModule)+import Tricorder.Daemon.Ghci.Process qualified as Ghci.Process import Tricorder.Session.Stage qualified as Stage import Tricorder.Session.Stage.Eval.Command qualified as EvalCommand import Tricorder.Session.Target qualified as Target@@ -45,13 +46,13 @@     -- in a fresh GHCi session started in that file's module context.     EvaluateComments         :: CommandTemplate 'Stage.Eval-        -> NonEmpty (LoadedModule, NonEmpty Eval.Comment)+        -> NonEmpty (Ghci.LoadedModule, NonEmpty Eval.Comment)         -> EvalCommentRunner m (NonEmpty Eval.Evaluation)     -- | Extract eval comments from provided source files. Returns a map of all     -- files that have at least 1 eval comment.     FindEvalCommentsInModules-        :: Map FilePath LoadedModule-        -> EvalCommentRunner m [(LoadedModule, NonEmpty Eval.Comment)]+        :: Map FilePath Ghci.LoadedModule+        -> EvalCommentRunner m [(Ghci.LoadedModule, NonEmpty Eval.Comment)]   makeEffect ''EvalCommentRunner@@ -121,10 +122,10 @@             | otherwise = expr         command = EvalCommand.render template [Target.Bare moduleName]     sessionResult <- trySync-        $ withGhciProcess def command projectRoot noProgress noSetup \ghci _ -> do-            _ <- execGhci ghci (":m *" <> moduleName) noProgress+        $ Ghci.Process.with def command projectRoot noProgress noSetup \ghci _ -> do+            _ <- Ghci.Process.exec ghci (":m *" <> moduleName) noProgress             for comments \comment -> do-                outputResult <- trySync $ execGhci ghci (wrapForGhci comment.expression) noProgress+                outputResult <- trySync $ Ghci.Process.exec ghci (wrapForGhci comment.expression) noProgress                 pure                     Eval.Evaluation                         { file = relPath
+ src/Tricorder/Daemon/Ghci/Config.hs view
@@ -0,0 +1,25 @@+module Tricorder.Daemon.Ghci.Config (Config (..)) where++import Atelier.Time (Second)+import Data.Default (Default (..))+++-- | Configuration for GHCi process management.+data Config = Config+    { startupTimeout :: Second+    -- ^ How long to wait for the GHCi version banner on startup.+    , shutdownTimeout :: Second+    -- ^ How long to wait for the process to exit gracefully before force-killing.+    , extraSetupCommands :: [Text]+    -- ^ Additional GHCi commands to send after the fixed setup block.+    -- Use this to inject session-wide options such as @:set -XOverloadedStrings@.+    }+++instance Default Config where+    def =+        Config+            { startupTimeout = 60+            , shutdownTimeout = 5+            , extraSetupCommands = []+            }
+ src/Tricorder/Daemon/Ghci/Controls.hs view
@@ -0,0 +1,25 @@+module Tricorder.Daemon.Ghci.Controls+    ( Controls (..)+    , lift+    )+where++import Tricorder.Daemon.Ghci.LoadResult (LoadResult)+++data Controls m = Controls+    { reload :: m LoadResult+    , interrupt :: m ()+    , add :: FilePath -> m LoadResult+    , unadd :: Text -> m LoadResult+    }+++lift :: (forall a. m a -> n a) -> Controls m -> Controls n+lift f ctrls =+    Controls+        { reload = f ctrls.reload+        , interrupt = f ctrls.interrupt+        , add = f . ctrls.add+        , unadd = f . ctrls.unadd+        }
+ src/Tricorder/Daemon/Ghci/Diagnostic.hs view
@@ -0,0 +1,56 @@+module Tricorder.Daemon.Ghci.Diagnostic+    ( unattributedFailure+    , headerParser+    )+where++import Data.Char (isAlpha)+import Text.Megaparsec (getInput, takeWhile1P)+import Text.Megaparsec.Char (char)+import Prelude hiding (many)++import Data.Text qualified as T++import Tricorder.Build (Diagnostic (..), Severity (..))+import Tricorder.Daemon.Ghci.Position (Position)+import Tricorder.Daemon.Ghci.TextParser (TextParser)++import Tricorder.Daemon.Ghci.Position qualified as Position+++-- | Synthetic diagnostic for a failed load with no located error. Without a+-- source span it cannot point anywhere, but it stops the build reading as+-- clean and tells the user where to look.+unattributedFailure :: Diagnostic+unattributedFailure =+    Diagnostic+        { severity = SError+        , file = "<no location info>"+        , line = 0+        , col = 0+        , endLine = 0+        , endCol = 0+        , title = "GHCi reported a failed load with no located error"+        , text =+            "GHCi reported a failed load with no located error.\nRun `tricorder log` to see the full GHCi output.\n"+        }+++-- | Parse the full diagnostic header:+--   @file:pos:@ or @drive:path:pos:@ for Windows.+-- Returns @(file, startPos, endPos, textAfterColon)@.+headerParser :: TextParser (Text, Position, Position, Text)+headerParser = do+    a <- takeWhile1P Nothing (/= ':')+    _ <- char ':'+    filePart <-+        if T.length a == 1 && isAlpha (T.head a)+            then do+                pathRest <- takeWhile1P Nothing (/= ':')+                _ <- char ':'+                pure (a <> ":" <> pathRest)+            else+                pure a+    (sp, ep) <- Position.parser+    afterPos <- getInput+    pure (filePart, sp, ep, afterPos)
+ src/Tricorder/Daemon/Ghci/InterruptDecision.hs view
@@ -0,0 +1,28 @@+module Tricorder.Daemon.Ghci.InterruptDecision+    ( InterruptDecision (..)+    , decide+    )+where++import Tricorder.Daemon.Ghci.Session.State qualified as Session (State)+import Tricorder.Daemon.Ghci.Session.State qualified as Session.State+++-- | The outcome of consulting the 'SessionState' when an interrupt arrives.+--+-- 'NoOpIdle' means the GHCi session is at the prompt — sending SIGINT and a+-- sync marker would dirty the buffers and desync the next 'execGhci'.+-- 'SendInterruptFor n' means a command was in flight (state 'Busy n') and+-- the caller should SIGINT the process group and re-write @markerFor n@ (that+-- command's own marker) to unblock the in-progress 'drainUntil'.+data InterruptDecision+    = NoOpIdle+    | SendInterruptFor Int+    deriving stock (Eq, Show)+++-- | Pure state machine for 'interruptGhci'. Returns the new 'SessionState'+-- to install along with the 'InterruptDecision' the caller should act on.+decide :: Session.State -> (Session.State, InterruptDecision)+decide s@(Session.State.Idle _) = (s, NoOpIdle)+decide (Session.State.Busy n) = (Session.State.Idle (n + 1), SendInterruptFor n)
+ src/Tricorder/Daemon/Ghci/LineParser.hs view
@@ -0,0 +1,66 @@+module Tricorder.Daemon.Ghci.LineParser+    ( LineStream (..)+    , LineParser+    , satisfyStripped+    , stripAnsi+    )+where++import Text.Megaparsec (Parsec, Stream (..), token)+import Prelude hiding (many)++import Data.Char qualified as Char+import Data.Text qualified as T+++-- ---------------------------------------------------------------------------+-- LineStream: a Stream instance for [Text] where Token = Text+-- ---------------------------------------------------------------------------++-- | Wrapper so we can define Stream / VisualStream / TraversableStream for+--   a list of 'Text' lines without orphan-instance conflicts.+newtype LineStream = LineStream [Text]+++instance Stream LineStream where+    type Token LineStream = Text+    type Tokens LineStream = [Text]+    tokenToChunk Proxy = pure+    tokensToChunk Proxy = id+    chunkToTokens Proxy = id+    chunkLength Proxy = length+    chunkEmpty Proxy = null+    take1_ (LineStream []) = Nothing+    take1_ (LineStream (t : ts)) = Just (t, LineStream ts)+    takeN_ n (LineStream s)+        | n <= 0 = Just ([], LineStream s)+        | null s = Nothing+        | otherwise = let (a, b) = splitAt n s in Just (a, LineStream b)+    takeWhile_ p (LineStream s) =+        let (a, b) = span p s in (a, LineStream b)+++-- | Parser over a stream of 'Text' lines.+type LineParser = Parsec Void LineStream+++-- | Consume a line whose stripped form satisfies the predicate.+-- Returns @(originalLine, strippedLine)@.+satisfyStripped :: (Text -> Bool) -> LineParser (Text, Text)+satisfyStripped p = token testLine mempty+  where+    testLine line =+        let stripped = stripAnsi line+        in  if p stripped then Just (line, stripped) else Nothing+++-- | Strip ANSI escape sequences of the form @ESC [ \<params\> \<letter\>@.+stripAnsi :: Text -> Text+stripAnsi t = case T.uncons t of+    Nothing -> t+    Just ('\ESC', rest) -> case T.uncons rest of+        Just ('[', rest') ->+            let afterParams = T.dropWhile (not . Char.isAlpha) rest'+            in  stripAnsi (T.drop 1 afterParams)+        _ -> T.cons '\ESC' (stripAnsi rest)+    Just (c, rest) -> T.cons c (stripAnsi rest)
+ src/Tricorder/Daemon/Ghci/Load.hs view
@@ -0,0 +1,143 @@+module Tricorder.Daemon.Ghci.Load+    ( Load (..)+    , parser+    , textParser+    )+where++import Text.Megaparsec (anySingle, choice, satisfy, takeWhile1P, takeWhileP)+import Text.Megaparsec.Char (char, string)++import Data.Char qualified as Char+import Data.Text qualified as T+import Text.Megaparsec.Char.Lexer qualified as L++import Tricorder.Daemon.Ghci.LineParser (LineParser)+import Tricorder.Daemon.Ghci.LoadOutcome (LoadOutcome)+import Tricorder.Daemon.Ghci.Position (Position (..))+import Tricorder.Daemon.Ghci.TextParser (TextParser)++import Tricorder.Daemon.Ghci.Diagnostic qualified as Diagnostic+import Tricorder.Daemon.Ghci.LineParser qualified as LineParser+import Tricorder.Daemon.Ghci.LoadOutcome qualified as LoadOutcome+import Tricorder.Daemon.Ghci.Loading qualified as Ghci (Loading (..))+import Tricorder.Daemon.Ghci.Loading qualified as Ghci.Loading+import Tricorder.Daemon.Ghci.Message qualified as Ghci+import Tricorder.Daemon.Ghci.Severity qualified as Severity+import Tricorder.Daemon.Ghci.TextParser qualified as TextParser+++-- | A structured item from GHCi's reload output.+data Load+    = Loading Ghci.Loading+    | Message Ghci.Message+    | LoadConfig FilePath+    | Summary LoadOutcome+    deriving stock (Eq, Show)+++-- | Parse one item (or skip one line) from the reload output.+parser :: LineParser (Maybe Load)+parser =+    choice+        [ -- Pattern: "Loaded GHCi configuration from <path>"+          fmap Just $ do+            (_, stripped) <- LineParser.satisfyStripped ("Loaded GHCi configuration from " `T.isPrefixOf`)+            let file = toString $ T.drop (T.length "Loaded GHCi configuration from ") stripped+            pure (LoadConfig file)+        , -- Pattern: "[N of M] Compiling ..."+          do+            (_, stripped) <- LineParser.satisfyStripped ("[" `T.isPrefixOf`)+            pure (TextParser.run textParser stripped)+        , -- Pattern: terminal summary line "Ok, ..." / "Failed, ..."+          fmap Just $ do+            (_, stripped) <-+                LineParser.satisfyStripped (\s -> "Ok, " `T.isPrefixOf` s || "Failed, " `T.isPrefixOf` s)+            pure+                $ Summary+                $ if "Failed, " `T.isPrefixOf` stripped+                    then LoadOutcome.LoadFailed+                    else LoadOutcome.LoadSucceeded+        , -- Pattern: "<no location info>: error:"+          fmap Just $ do+            (origLine, _) <- LineParser.satisfyStripped ("<no location info>: error:" `T.isPrefixOf`)+            body <- many (satisfy isMessageBody)+            pure+                $ Message+                    Ghci.Message+                        { severity = Severity.Error+                        , file = "<no location info>"+                        , startPos = Position 0 0+                        , endPos = Position 0 0+                        , messageLines = origLine : body+                        }+        , -- Pattern: diagnostic "file:pos:severity: ..."+          do+            (origLine, stripped) <-+                LineParser.satisfyStripped+                    ( \s ->+                        not (T.null s)+                            && not (" " `T.isPrefixOf` s)+                            && not ("\t" `T.isPrefixOf` s)+                    )+            case TextParser.run Diagnostic.headerParser stripped of+                Nothing -> pure Nothing+                Just (fileT, sp, ep, afterPos) ->+                    let lower = T.toLower (T.stripStart afterPos)+                    in  case Severity.parse lower of+                            Nothing -> pure Nothing+                            Just sev -> do+                                body <- many (satisfy isMessageBody)+                                pure+                                    $ Just+                                    $ Message+                                        Ghci.Message+                                            { severity = sev+                                            , file = toString fileT+                                            , startPos = sp+                                            , endPos = ep+                                            , messageLines = origLine : body+                                            }+        , -- Fallback: skip any other line+          fmap (const Nothing) anySingle+        ]+++-- | Parse a "[N of M] Compiling Mod ( file, ... )" loading line.+textParser :: TextParser Load+textParser = do+    _ <- char '['+    _ <- takeWhileP Nothing (== ' ')+    n <- L.decimal+    _ <- string " of "+    m <- L.decimal+    _ <- takeWhileP Nothing (== ' ')+    _ <- char ']'+    _ <- takeWhile1P Nothing (== ' ')+    _ <- string "Compiling"+    _ <- takeWhile1P Nothing (== ' ')+    modName <- takeWhile1P Nothing (not . Char.isSpace)+    _ <- takeWhileP Nothing (/= '(')+    _ <- char '('+    _ <- takeWhileP Nothing (== ' ')+    filePath <- takeWhile1P Nothing (\c -> c /= ',' && c /= ')')+    pure+        $ Loading+            Ghci.Loading+                { Ghci.Loading.index = n+                , Ghci.Loading.total = m+                , Ghci.Loading.moduleName = modName+                , Ghci.Loading.sourceFile = toString (T.stripEnd filePath)+                }+++-- | Check if a line is a continuation of a diagnostic message body.+isMessageBody :: Text -> Bool+isMessageBody line =+    " " `T.isPrefixOf` line+        || "\t" `T.isPrefixOf` line+        || case T.break (== '|') line of+            (prefix, rest)+                | not (T.null rest) ->+                    T.all (\c -> Char.isSpace c || Char.isDigit c) prefix+            _ -> False
+ src/Tricorder/Daemon/Ghci/LoadOutcome.hs view
@@ -0,0 +1,7 @@+module Tricorder.Daemon.Ghci.LoadOutcome (LoadOutcome (..)) where+++-- | The terminal summary GHCi emits to close a load: @Ok, N modules loaded.@+-- on success or @Failed, …@ on failure.+data LoadOutcome = LoadSucceeded | LoadFailed+    deriving stock (Eq, Show)
+ src/Tricorder/Daemon/Ghci/LoadResult.hs view
@@ -0,0 +1,54 @@+module Tricorder.Daemon.Ghci.LoadResult+    ( LoadResult (..)+    , resolveKnownTargets+    )+where++import Data.Map.Strict qualified as Map+import Data.Set qualified as Set++import Tricorder.Build (Diagnostic)+import Tricorder.Daemon.Ghci.LoadedModule (LoadedModule (..))+++-- | The result of a GHCi load or reload operation.+data LoadResult = LoadResult+    { moduleCount :: Int+    , compiledFiles :: Set FilePath+    -- ^ Files compiled in this cycle (derived from 'GLoading' items).+    -- Used by the session layer to decide which files' previous diagnostics to replace vs. retain.+    , loadedModules :: Map FilePath LoadedModule+    -- ^ Map from canonical absolute path to module metadata, derived from @:show modules@ output.+    -- Lists only modules that compiled successfully this cycle — GHCi drops+    -- failed-compile modules from @:show modules@.+    , targetNames :: [Text]+    -- ^ Raw entries from @:show targets@ — typically dotted module names+    -- in @cabal repl --enable-multi-repl@. Unlike 'loadedModules' this+    -- survives failed compiles, so the Builder feeds it into+    -- @KnownTargetNames@ for the dispatcher's fallback lookup.+    , diagnostics :: [Diagnostic]+    }+    deriving stock (Eq, Show)+++-- | Path↔module-name map for the next cycle: this round's @:show modules@+-- plus carryover from @prev@ for targets that failed mid-session and so+-- dropped out. Never-compiled targets are absent here (no path↔name+-- mapping); the dispatcher recognises those via 'KnownTargetNames'.+resolveKnownTargets+    :: Map FilePath LoadedModule+    -- ^ Previous known-targets map (carryover source).+    -> LoadResult+    -> Map FilePath LoadedModule+resolveKnownTargets prev lr =+    let primary = lr.loadedModules+        primaryNames = Set.fromList [lm.moduleName | lm <- Map.elems primary]+        prevByName = Map.fromList [(lm.moduleName, (path, lm)) | (path, lm) <- Map.toList prev]+        carryover =+            Map.fromList+                [ (path, lm)+                | name <- lr.targetNames+                , not (Set.member name primaryNames)+                , Just (path, lm) <- [Map.lookup name prevByName]+                ]+    in  Map.union primary carryover
+ src/Tricorder/Daemon/Ghci/LoadedModule.hs view
@@ -0,0 +1,13 @@+module Tricorder.Daemon.Ghci.LoadedModule+    ( LoadedModule (..)+    )+where+++-- | A module currently loaded in the GHCi session.+data LoadedModule = LoadedModule+    { relPath :: FilePath+    -- ^ Path relative to the project root (e.g. @"./src/Foo.hs"@).+    , moduleName :: Text+    }+    deriving stock (Eq, Show)
+ src/Tricorder/Daemon/Ghci/Loading.hs view
@@ -0,0 +1,13 @@+module Tricorder.Daemon.Ghci.Loading (Loading (..)) where+++-- | Payload for a @[N of M] Compiling Mod ( file, ... )@ line.+data Loading = Loading+    { index :: Int+    -- ^ N (this module's index in the compilation sequence)+    , total :: Int+    -- ^ M (total modules to compile)+    , moduleName :: Text+    , sourceFile :: FilePath+    }+    deriving stock (Eq, Show)
+ src/Tricorder/Daemon/Ghci/Message.hs view
@@ -0,0 +1,17 @@+module Tricorder.Daemon.Ghci.Message (Message (..)) where++import Tricorder.Daemon.Ghci.Position (Position)+import Tricorder.Daemon.Ghci.Severity (Severity)+++-- | Payload for a compiler diagnostic (error or warning).+data Message = Message+    { severity :: Severity+    , file :: FilePath+    , startPos :: Position+    , endPos :: Position+    -- ^ equals 'startPos' when no span+    , messageLines :: [Text]+    -- ^ raw lines (with any ANSI), header first+    }+    deriving stock (Eq, Show)
+ src/Tricorder/Daemon/Ghci/Output.hs view
@@ -0,0 +1,57 @@+module Tricorder.Daemon.Ghci.Output+    ( parseShowModules+    , parseShowTargets+    )+where++import Data.Char (isSpace)+import Text.Megaparsec+import Text.Megaparsec.Char (char)+import Prelude hiding (many)++import Data.Text qualified as T++import Tricorder.Daemon.Ghci.LineParser (LineParser, LineStream (..))+import Tricorder.Daemon.Ghci.TextParser (TextParser)++import Tricorder.Daemon.Ghci.LineParser qualified as LineParser+import Tricorder.Daemon.Ghci.TextParser qualified as TextParser+++-- | Parse the output of @:show modules@ into (module name, file path) pairs.+parseShowModules :: [Text] -> [(Text, FilePath)]+parseShowModules ls =+    case parse (catMaybes <$> many showModuleLine <* eof) "" (LineStream ls) of+        Right items -> items+        Left _ -> []+++-- | Parse or skip one line of @:show modules@ output.+showModuleLine :: LineParser (Maybe (Text, FilePath))+showModuleLine = do+    line <- anySingle+    pure $ TextParser.run showModuleLineP (LineParser.stripAnsi line)+++showModuleLineP :: TextParser (Text, FilePath)+showModuleLineP = do+    modName <- takeWhile1P Nothing (not . isSpace)+    _ <- takeWhileP Nothing (/= '(')+    _ <- char '('+    _ <- char ' '+    filePath <- takeWhile1P Nothing (\c -> c /= ',' && c /= ')')+    pure (modName, toString (T.stripEnd filePath))+++-- | Parse the output of @:show targets@.+--+-- Each line names one target. In @cabal repl --enable-multi-repl@ these are+-- dotted module names (e.g. @"Foo.Bar"@); in plain @ghci@ sessions they can+-- also be file paths. A leading @*@ marks the active interactive target and+-- is stripped. Blank lines are skipped.+parseShowTargets :: [Text] -> [Text]+parseShowTargets = mapMaybe parseLine+  where+    parseLine raw =+        let cleaned = T.strip $ T.dropWhile (== '*') $ T.strip $ LineParser.stripAnsi raw+        in  if T.null cleaned then Nothing else Just cleaned
+ src/Tricorder/Daemon/Ghci/Path.hs view
@@ -0,0 +1,41 @@+module Tricorder.Daemon.Ghci.Path+    ( isLocationLess+    , pathSuffixesAsModuleName+    )+where++import System.FilePath (dropExtension, normalise, splitDirectories)++import Data.List qualified as List+import Data.Text qualified as T+++-- | Whether a diagnostic file is a GHCi location-less pseudo-file — a fully+-- @\<…\>@-bracketed marker such as @\<no location info\>@ or @\<interactive\>@+-- rather than a real source path. These carry no source span but can still+-- represent genuine build-level failures, so callers treat them specially+-- (kept rather than dropped, cleared every cycle rather than retained).+--+-- We require both the opening @\<@ and a closing @\>@ so a real (if exotic)+-- path that merely starts with @\<@, e.g. @\<generated\>/Foo.hs@, is not+-- mistaken for a marker.+isLocationLess :: FilePath -> Bool+isLocationLess f = "<" `List.isPrefixOf` f && ">" `List.isSuffixOf` f+++-- | Candidate dotted module names for a file path.+--+-- We can't convert dotted target names to paths without cabal's+-- source-dirs, so callers check the other direction: any uppercase-segment+-- suffix of the path (with @/@ → @.@, extension dropped) is a candidate.+-- E.g. @./tricorder/src/Tricorder/Version.hs@ yields candidates+-- @"Tricorder.Version"@ and @"Version"@.+pathSuffixesAsModuleName :: FilePath -> [Text]+pathSuffixesAsModuleName fp =+    let segments = filter (not . null) (splitDirectories (dropExtension (normalise fp)))+        upperSegments = dropWhile (not . startsUpper) segments+        suffixes = takeWhile (not . null) (iterate (drop 1) upperSegments)+    in  [T.intercalate "." (map toText s) | s <- suffixes]+  where+    startsUpper (c : _) = c >= 'A' && c <= 'Z'+    startsUpper _ = False
+ src/Tricorder/Daemon/Ghci/Position.hs view
@@ -0,0 +1,56 @@+module Tricorder.Daemon.Ghci.Position+    ( Position (..)+    , parser+    )+where++import Text.Megaparsec (Parsec, choice)+import Text.Megaparsec.Char (char)+import Prelude hiding (many)++import Text.Megaparsec.Char.Lexer qualified as L+++-- | A source position (1-based line and column). @(0, 0)@ when unavailable.+data Position = Position+    { line :: Int+    , col :: Int+    }+    deriving stock (Eq, Show)+++-- | Parse a GHCi position in one of the formats:+--   @(L1,C1)-(L2,C2):@, @L:C:@, or @L:C-C2:@.+-- Consumes the trailing colon.+parser :: Parsec Void Text (Position, Position)+parser = parenForm <|> simpleForm+  where+    parenForm = do+        _ <- char '('+        l1 <- L.decimal+        _ <- char ','+        c1 <- L.decimal+        _ <- char ')'+        _ <- char '-'+        _ <- char '('+        _ <- optional (char '(') -- some GHCi versions emit "(L1,C1)-((L2,C2):"+        l2 <- L.decimal+        _ <- char ','+        c2 <- L.decimal+        _ <- char ')'+        _ <- char ':'+        pure (Position l1 c1, Position l2 c2)+    simpleForm = do+        l <- L.decimal+        _ <- char ':'+        c <- L.decimal+        choice+            [ do+                _ <- char ':'+                pure (Position l c, Position l c)+            , do+                _ <- char '-'+                c2 <- L.decimal+                _ <- char ':'+                pure (Position l c, Position l c2)+            ]
+ src/Tricorder/Daemon/Ghci/Process.hs view
@@ -0,0 +1,547 @@+module Tricorder.Daemon.Ghci.Process+    ( Process (..)+    , Error (..)+    , UnexpectedExit (..)+    , with+    , waitForBannerOrFail+    , exec+    , drainUntil+    , interruptGhci+    , terminateProcess+    , collectResult+    , reload+    , add+    , unadd+    )+where++import Atelier.Effects.Conc (Conc)+import Atelier.Effects.File (BufferMode (..), File, Handle)+import Atelier.Effects.Log (Log)+import Atelier.Effects.Process+    ( RunningProcess+    , createPipe+    , getStderr+    , getStdin+    , getStdout+    , setStderr+    , setStdin+    , setStdout+    , setWorkingDir+    , shell+    )+import Atelier.Effects.Timeout (Timeout, timeout)+import Control.Concurrent.STM (TVar, modifyTVar', readTVar, retry, writeTVar)+import Data.Sequence ((|>))+import Data.Time.Units (Second)+import Effectful.Concurrent (Concurrent)+import Effectful.Concurrent.STM (atomically, newTVarIO)+import Effectful.Exception (finally, throwIO, trySync)++import Atelier.Effects.Conc qualified as Conc+import Atelier.Effects.File qualified as File+import Atelier.Effects.Log qualified as Log+import Atelier.Effects.Process qualified as Atelier+import Atelier.Effects.Process qualified as Process+import Data.Text qualified as T++import Tricorder.Daemon.Ghci.Config (Config (..))+import Tricorder.Daemon.Ghci.LoadResult (LoadResult (..))+import Tricorder.Daemon.Ghci.Loading (Loading)+import Tricorder.Daemon.Ghci.Result (parseProgressLine)+import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))++import Tricorder.Daemon.Ghci.Diagnostic qualified as Diagnostic+import Tricorder.Daemon.Ghci.InterruptDecision qualified as InterruptDecision+import Tricorder.Daemon.Ghci.LineParser qualified as LineParser+import Tricorder.Daemon.Ghci.Output qualified as Output+import Tricorder.Daemon.Ghci.Result qualified as Result+import Tricorder.Daemon.Ghci.Session.State qualified as Session (State)+import Tricorder.Daemon.Ghci.Session.State qualified as Session.State+++-- | A handle to a running GHCi subprocess.+data Process = Process+    { stdin :: Handle+    , stdout :: Handle+    , stderr :: Handle+    , handle :: RunningProcess Handle Handle Handle+    , stateVar :: TVar Session.State+    }+++-- | Errors that can occur during GHCi process management.+data Error+    = StartupTimeout+    | UnexpectedExit UnexpectedExit+    | -- | The build command exited (or printed nothing parseable) before GHCi+      -- produced its version banner. The 'Text' is the captured stderr+stdout+      -- output so callers can surface a useful error (e.g. cabal's dependency+      -- resolution failure).+      StartupFailed Text+    deriving stock (Eq, Show)+++instance Exception Error where+    displayException :: Error -> String+    displayException StartupTimeout = "GHCi timed out while starting"+    displayException (UnexpectedExit unexpected) =+        "Encountered error while waiting for command output from GHCi: " <> toString unexpected.errorMessage+    displayException (StartupFailed msg) = "Failed to start GHCi: " <> toString msg+++data UnexpectedExit = MkUnexpectedExit+    { marker :: Text+    , errorMessage :: Text+    }+    deriving stock (Eq, Show)+++-- | Set up the GHCi protocol on an already-started process and return its+-- handle together with the output captured during startup.+--+-- The spawn and group teardown are owned by 'withProcess'.+setupProcess+    :: ( Conc :> es+       , Concurrent :> es+       , File :> es+       , Log :> es+       , Timeout :> es+       )+    => Config+    -> RunningProcess Handle Handle Handle+    -> (Loading -> Eff es ())+    -- ^ Called as each @[N of M] Compiling …@ line is streamed during the+    -- initial-build drain, so the UI can update the progress bar live+    -- instead of replaying everything once compilation finishes.+    -> (Process -> Eff es ())+    -- ^ @onReady@. Called once the 'Process' is constructed but before the+    -- banner wait and initial-build drain — so callers can register the process+    -- for interruption while the slow @cabal repl@ startup (dependency build+    -- and recompilation) is still in progress.+    -> Eff es (Process, [Text])+setupProcess config p onProgress onReady = do+    let inp = getStdin p+        out = getStdout p+        err = getStderr p+    File.hSetBuffering inp LineBuffering+    File.hSetBuffering out LineBuffering+    File.hSetBuffering err LineBuffering++    -- Register the process for interruption before the (possibly slow) banner+    -- wait, so an interrupt during @cabal repl@ startup can terminate it.+    stateVar <- newTVarIO (Session.State.Idle 0)+    let ghciProcess =+            Process+                { stdin = inp+                , stdout = out+                , stderr = err+                , handle = p+                , stateVar = stateVar+                }+    onReady ghciProcess++    -- Send a blank line to kick GHCi into producing output+    File.hPutTextLn inp ""+    File.hFlush inp++    -- Wait for the version banner. We concurrently capture stderr so that if+    -- the build command exits before printing a banner (e.g. cabal's+    -- dependency resolution fails) we can surface its error output.+    waitForBannerOrFail config.startupTimeout out err++    -- Send fixed setup commands (protocol requirements)+    File.hPutTextLn inp ":set prompt \"\""+    File.hPutTextLn inp ":set prompt-cont \"\""+    -- Send any caller-supplied extra setup commands+    for_ config.extraSetupCommands \c ->+        File.hPutTextLn inp c+    File.hFlush inp++    -- Sync: drain until marker 1 seen, then set counter to 2.+    -- Capture lines from both streams — the stderr output contains the initial+    -- compilation progress and any startup diagnostics. The line hook fires+    -- 'onProgress' for each "[N of M] Compiling …" line as it arrives, so the+    -- UI can update the progress bar live during the initial build.+    let marker1 = markerFor 1+        hook = progressLineHook onProgress+    sendSyncCommand inp marker1+    initialLines <- Conc.scoped do+        stdoutThread <- Conc.fork $ drainUntil out marker1 hook+        stderrThread <- Conc.fork $ drainUntil err marker1 hook+        stdoutLines <- Conc.await stdoutThread+        stderrLines <- Conc.await stderrThread+        pure (stdoutLines ++ stderrLines)+    atomically $ writeTVar stateVar (Session.State.Idle 2)++    pure (ghciProcess, initialLines)+++-- | Run a GHCi @cabal repl@ session for the duration of @action@.+--+-- The session runs in its own process group, so the whole group is torn down on+-- exit; 'quitGhci' first asks GHCi to @:quit@ for a graceful shutdown. The+-- action receives the process handle and the output captured during startup.+-- See 'setupProcess' for the @onProgress@ and @onReady@ callbacks.+with+    :: (Atelier.Process :> es, Conc :> es, Concurrent :> es, File :> es, Log :> es, Timeout :> es)+    => Config+    -> RenderedCommand stage+    -> FilePath+    -> (Loading -> Eff es ())+    -> (Process -> Eff es ())+    -> (Process -> [Text] -> Eff es a)+    -> Eff es a+with config cmd dir onProgress onReady action =+    Process.withProcessGroup processConfig \p -> do+        (ghciProcess, initialLines) <- setupProcess config p onProgress onReady+        action ghciProcess initialLines `finally` quitGhci config ghciProcess+  where+    processConfig =+        setStdin createPipe+            $ setStdout createPipe+            $ setStderr createPipe+            $ setWorkingDir dir+            $ shell+            $ toString cmd.getRenderedCommand+++-- | Execute a command in GHCi and return the combined stdout+stderr output+-- lines. The @onProgress@ callback fires for each @[N of M] Compiling …@ line+-- as it arrives, so reload/add/unadd progress is streamed live to the UI.+-- Pass @\\_ -> pure ()@ for commands that do not trigger compilation.+exec+    :: ( Conc :> es+       , Concurrent :> es+       , File :> es+       , Log :> es+       )+    => Process -> Text -> (Loading -> Eff es ()) -> Eff es [Text]+exec ghciProcess command onProgress = do+    n <- atomically do+        readTVar ghciProcess.stateVar >>= \case+            Session.State.Idle n -> writeTVar ghciProcess.stateVar (Session.State.Busy n) $> n+            Session.State.Busy _ -> retry+    doExec n `finally` atomically (writeTVar ghciProcess.stateVar (Session.State.Idle (n + 1)))+  where+    doExec n = do+        let marker = markerFor n+            hook = progressLineHook onProgress+        File.hPutTextLn ghciProcess.stdin command+        File.hFlush ghciProcess.stdin+        sendSyncCommand ghciProcess.stdin marker+        -- Scoped so that an exception from one drain (e.g. 'UnexpectedExit'+        -- when the underlying process is terminated mid-command) is+        -- contained here, re-raised by 'await', and caught by the caller's+        -- 'trySync'. Without 'scoped', Ki propagates the exception to the+        -- \*ambient* scope — typically the builder's listener scope — which+        -- tears down the whole builder loop instead of just failing this+        -- one command.+        (stdoutLines, stderrLines) <- Conc.scoped do+            stdoutThread <- Conc.fork $ drainUntil ghciProcess.stdout marker hook+            stderrThread <- Conc.fork $ drainUntil ghciProcess.stderr marker hook+            (,) <$> Conc.await stdoutThread <*> Conc.await stderrThread+        pure (stdoutLines ++ stderrLines)+++-- | Interrupt the currently running GHCi command (if any).+--+-- If a command is in progress (state 'Busy n'), sends SIGINT to the GHCi+-- process group and re-writes /that command's own/ sync marker (@markerFor n@)+-- so the in-progress 'drainUntil', which is waiting for exactly that marker,+-- unblocks. The next command runs as @n + 1@ and waits for @markerFor (n + 1)@,+-- so any duplicate @markerFor n@ left in the buffers is skipped by 'drainUntil'+-- rather than mistaken for the next command's marker.+--+-- When GHCi is 'Idle' this is a true no-op: sending SIGINT and a sync marker to+-- an idle GHCi leaves leftover marker output in the buffers.+interruptGhci :: (Atelier.Process :> es, Concurrent :> es, File :> es) => Process -> Eff es ()+interruptGhci ghciProcess = do+    decision <- atomically do+        s <- readTVar ghciProcess.stateVar+        let (s', d) = InterruptDecision.decide s+        writeTVar ghciProcess.stateVar s'+        pure d+    case decision of+        InterruptDecision.NoOpIdle -> pure ()+        InterruptDecision.SendInterruptFor n -> do+            Process.interruptProcessGroup ghciProcess.handle+            sendSyncCommand ghciProcess.stdin (markerFor n)+++-- | Forcefully terminate a GHCi process and its whole group.+--+-- Stronger than 'interruptGhci': intended for one-shot processes (such as the+-- per-suite @cabal repl test:…@ used by the test runner) where SIGINT is+-- insufficient — test frameworks like @hspec@ and @tasty@ install SIGINT+-- handlers that finalise the current run rather than aborting it. Safe to call+-- from another thread while the session is running.+terminateProcess :: (Atelier.Process :> es) => Process -> Eff es ()+terminateProcess ghciProcess =+    Process.terminateProcessGroup ghciProcess.handle+++-- | Ask GHCi to @:quit@ and wait briefly for it to exit cleanly, letting it+-- shut down gracefully before the enclosing 'withProcess' tears down the+-- group. Never throws.+quitGhci :: (Atelier.Process :> es, File :> es, Timeout :> es) => Config -> Process -> Eff es ()+quitGhci config ghciProcess = do+    void $ trySync $ do+        File.hPutTextLn ghciProcess.stdin ":quit"+        File.hFlush ghciProcess.stdin+    void $ timeout config.shutdownTimeout (Process.waitExitCode ghciProcess.handle)+++-- ---------------------------------------------------------------------------+-- Internal helpers++-- | Build the finish marker for counter value @n@.+markerFor :: Int -> Text+markerFor n = markerPrefix <> show n <> "~#"+++-- | The prefix shared by all finish markers.+markerPrefix :: Text+markerPrefix = "#~TRI-FINISH-"+++-- | Write the synchronisation Haskell statements to GHCi stdin.+--+-- After each user command, these cause GHCi to emit the finish marker on both+-- stdout and stderr, so 'drainUntil' knows when to stop.+--+-- The marker is emitted as two standalone, fully-qualified statements — one per+-- stream — using only 'System.IO.hPutStrLn'. This is deliberate: a+-- SIGINT-interrupted ':reload' empties GHCi's interactive scope, dropping the+-- implicit @import Prelude@, so bare names like @putStrLn@ (and even the @>>@+-- operator) fall out of scope. A marker built from those would /error/ instead+-- of printing — its marker would never appear and 'drainUntil' would block+-- forever waiting for it (the "stuck Building…" stall). Fully-qualified+-- 'System.IO.hPutStrLn' resolves via GHCi's implicit qualified imports even+-- with an emptied scope, so the marker survives an interrupt.+sendSyncCommand :: (File :> es) => Handle -> Text -> Eff es ()+sendSyncCommand h marker = do+    -- Use show to produce a valid Haskell string literal for the marker text.+    let markerLit = toText (show @String (toString marker)) -- e.g. "\"#~TRI-FINISH-3~#\""+    File.hPutTextLn h ("System.IO.hPutStrLn System.IO.stdout " <> markerLit)+    File.hPutTextLn h ("System.IO.hPutStrLn System.IO.stderr " <> markerLit)+    File.hFlush h+++-- | Read lines from a handle until /this command's/ finish marker is seen (or+-- EOF).+--+-- Stops only on a line containing the exact @marker@ it was given. A line+-- carrying a /different/ finish marker — a stale leftover from a prior command+-- that was interrupted mid-flight — is skipped rather than matched, so it can+-- never make a later drain return prematurely (the "0 modules"/hang desync).+-- Each ordinary line is passed to @onLine@ as it arrives, so callers can stream+-- progress without waiting for the full drain to complete. Returns accumulated+-- non-marker lines in order. Throws 'UnexpectedExit' on EOF before the marker.+drainUntil :: (File :> es, Log :> es) => Handle -> Text -> (Text -> Eff es ()) -> Eff es [Text]+drainUntil h marker onLine = go mempty+  where+    go acc = do+        result <- trySync $ File.hGetLine h+        case result of+            Left ex -> do+                let accumulatedLines = T.intercalate "\n" $ toList acc+                    errorMessage =+                        T.intercalate+                            "\n"+                            [ "Reached EOF before reading marker from GHCi."+                            , "Was looking for marker '" <> marker <> "', but no such marker was found."+                            , ""+                            ]+                            <> if T.null accumulatedLines+                                then+                                    T.intercalate+                                        "\n"+                                        [ "GHCi returned no output before we reached what we believe is EOF."+                                        , "Got the following exception when attempting to read from GHCi:"+                                        , toText $ displayException ex+                                        ]+                                else+                                    T.intercalate+                                        "\n"+                                        [ "Accumulated output from GHCi so far:"+                                        , accumulatedLines+                                        ]+                Log.err errorMessage+                throwIO+                    $ UnexpectedExit+                    $ MkUnexpectedExit {marker, errorMessage}+            Right line+                | marker `T.isInfixOf` line -> pure $ toList acc+                -- A stale marker from an interrupted command: drop it, keep going.+                | markerPrefix `T.isInfixOf` line -> go acc+                | otherwise -> do+                    onLine line+                    go $ acc |> line+++-- | Convert a 'GhciLoading' progress callback into a per-line hook suitable+-- for 'drainUntil'. Non-progress lines are ignored.+progressLineHook :: (Loading -> Eff es ()) -> Text -> Eff es ()+progressLineHook onProgress line = traverse_ onProgress (parseProgressLine line)+++-- | Wait up to the given number of seconds for a GHCi version banner on+-- stdout, capturing stderr (and any non-banner stdout lines) in case the+-- build command fails before printing a banner.+--+-- Throws 'StartupFailed' with the captured output if stdout EOFs (the build+-- command exited) or 'StartupTimeout' if the banner never arrives.+waitForBannerOrFail+    :: ( Conc :> es+       , Concurrent :> es+       , File :> es+       , Timeout :> es+       )+    => Second -> Handle -> Handle -> Eff es ()+waitForBannerOrFail delay out err = do+    capturedVar <- newTVarIO ([] :: [Text])+    let captureLine line = atomically $ modifyTVar' capturedVar (line :)+        drainStderr = drainUntilEof err captureLine+        watchStdout = waitForBannerStdout out captureLine++    Conc.scoped do+        stderrThread <- Conc.fork drainStderr+        result <- timeout delay $ trySync watchStdout+        case result of+            Just (Right ()) -> pure ()+            Just (Left ex) -> do+                -- stdout EOF before the banner: the build command exited. Wait+                -- for the stderr drain to reach EOF too (bounded, in case the+                -- pipe lingers) so we surface its *complete* output — e.g.+                -- cabal's dependency-resolution error — rather than whatever+                -- the drain happened to have read so far.+                _ <- timeout (1 :: Second) (Conc.await stderrThread)+                captured <- atomically $ readTVar capturedVar+                throwIO $ StartupFailed $ fromMaybe (startupExitedMessage ex) (renderCapturedLines captured)+            Nothing -> do+                captured <- atomically $ readTVar capturedVar+                throwIO $ maybe StartupTimeout StartupFailed (renderCapturedLines captured)+++waitForBannerStdout :: (File :> es) => Handle -> (Text -> Eff es ()) -> Eff es ()+waitForBannerStdout h captureLine = go+  where+    isVersionLine :: Text -> Bool+    isVersionLine line =+        let stripped = LineParser.stripAnsi line+        in  "GHCi, version " `T.isInfixOf` stripped+                || "GHCJSi, version " `T.isInfixOf` stripped+                || "Clashi, version " `T.isInfixOf` stripped++    go = do+        result <- trySync $ File.hGetLine h+        case result of+            Left ex -> throwIO ex+            Right line ->+                if isVersionLine line+                    then+                        pure ()+                    else do+                        captureLine line+                        go+++drainUntilEof :: (File :> es) => Handle -> (Text -> Eff es ()) -> Eff es ()+drainUntilEof h onLine = go+  where+    go = do+        result <- trySync $ File.hGetLine h+        case result of+            Left _ -> pure ()+            Right line -> onLine line >> go+++-- | Render the captured (reverse-order) output lines into an error message,+-- stripping ANSI escapes and dropping blank lines. Returns 'Nothing' when+-- nothing useful remains, so callers can fall back to a generic message.+renderCapturedLines :: [Text] -> Maybe Text+renderCapturedLines capturedRev =+    let cleaned = filter (not . T.null . T.strip) (map LineParser.stripAnsi (reverse capturedRev))+    in  if null cleaned then Nothing else Just (T.intercalate "\n" cleaned)+++-- | Fallback message when the build command exited before the banner without+-- printing anything we could capture.+startupExitedMessage :: SomeException -> Text+startupExitedMessage ex =+    "Build command exited before GHCi started: " <> toText (displayException ex)+++-- | Parse already-drained GHCi output lines into a 'LoadResult', fetching the+-- current module list via @:show modules@.+--+-- Progress is emitted live by 'drainUntil' as lines arrive, so no replay+-- callback is needed here — this function only assembles the final result.+collectResult+    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)+    => Process+    -> [Text]+    -> FilePath+    -> Eff es LoadResult+collectResult process lines' projectRoot = do+    let noProgress = \_ -> pure ()+    moduleLines <- exec process ":show modules" noProgress+    targetLines <- exec process ":show targets" noProgress+    let result =+            Result.collectResult+                projectRoot+                lines'+                (Output.parseShowModules moduleLines)+                (Output.parseShowTargets targetLines)+    -- A failed load with no located error produces only the synthetic+    -- 'unattributedFailure'. The parsed diagnostics tell the user nothing in+    -- that case, so log the raw GHCi output — it's the only way to see what+    -- actually went wrong, and the synthetic diagnostic points here.+    when (any (== Diagnostic.unattributedFailure) result.diagnostics)+        $ Log.info+        $ "GHCi reported a failed load with no located error. Full GHCi output:\n"+            <> T.unlines lines'+    pure result+++-- | Execute @:reload@ and return the assembled 'LoadResult'. Progress events+-- fire live via @onProgress@ as each @[N of M] Compiling …@ line is read.+reload+    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)+    => Process+    -> FilePath+    -> (Loading -> Eff es ())+    -> Eff es LoadResult+reload process projectRoot onProgress = do+    reloadLines <- exec process ":reload" onProgress+    collectResult process reloadLines projectRoot+++-- | Execute @:add@ for the given file and return the assembled 'LoadResult'.+-- Progress events fire live via @onProgress@ as compilation proceeds.+add+    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)+    => Process+    -> FilePath -- the file to :add+    -> FilePath -- projectRoot+    -> (Loading -> Eff es ())+    -> Eff es LoadResult+add process filePath projectRoot onProgress = do+    addLines <- exec process (":add " <> T.pack filePath) onProgress+    collectResult process addLines projectRoot+++-- | Execute @:unadd@ for the given module and return the assembled+-- 'LoadResult'. Progress events fire live via @onProgress@ as compilation+-- proceeds.+unadd+    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)+    => Process+    -> Text -- the module name to :unadd+    -> FilePath -- projectRoot+    -> (Loading -> Eff es ())+    -> Eff es LoadResult+unadd process moduleName projectRoot onProgress = do+    unaddLines <- exec process (":unadd " <> moduleName) onProgress+    collectResult process unaddLines projectRoot
+ src/Tricorder/Daemon/Ghci/Result.hs view
@@ -0,0 +1,230 @@+module Tricorder.Daemon.Ghci.Result+    ( collectResult+    , collectResultCustom+    , reloadFailed+    , parseProgressLine+    , parseReload+    , extractTitle+    , toAbsolute+    , toRelative+    )+where++import Data.Char (isDigit, isSpace, toLower)+import System.FilePath (isAbsolute, makeRelative, normalise, splitDirectories, (</>))+import Text.Megaparsec+import Prelude hiding (many)++import Data.List qualified as List+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text qualified as T++import Tricorder.Build (Diagnostic (..), Severity (..))+import Tricorder.Daemon.Ghci.LineParser (LineStream (..))+import Tricorder.Daemon.Ghci.Load (Load)+import Tricorder.Daemon.Ghci.LoadResult (LoadResult (..))+import Tricorder.Daemon.Ghci.LoadedModule (LoadedModule (..))+import Tricorder.Daemon.Ghci.Loading (Loading)+import Tricorder.Daemon.Ghci.Message (Message (..))+import Tricorder.Daemon.Ghci.Position (Position (..))++import Tricorder.Daemon.Ghci.Diagnostic qualified as Diagnostic+import Tricorder.Daemon.Ghci.LineParser qualified as LineParser+import Tricorder.Daemon.Ghci.Load qualified as Load+import Tricorder.Daemon.Ghci.LoadOutcome qualified as LoadOutcome+import Tricorder.Daemon.Ghci.Loading qualified as Loading+import Tricorder.Daemon.Ghci.Path qualified as Path+import Tricorder.Daemon.Ghci.Severity qualified as Severity+import Tricorder.Daemon.Ghci.TextParser qualified as TextParser+++-- ---------------------------------------------------------------------------+-- Line-level helpers+-- ---------------------------------------------------------------------------++-- ---------------------------------------------------------------------------+-- parseReload+-- ---------------------------------------------------------------------------++-- | Parse the output of @:reload@ from GHCi into structured items.+parseReload :: [Text] -> [Load]+parseReload ls =+    case parse (catMaybes <$> many Load.parser <* eof) "" (LineStream ls) of+        Right items -> items+        Left _ -> []+++-- | Parse a single line as a "[N of M] Compiling …" progress event.+--+-- Returns 'Nothing' for any line that is not a loading line. Used to stream+-- progress updates as GHCi emits them, rather than parsing the whole reload+-- output after the fact.+parseProgressLine :: Text -> Maybe Loading+parseProgressLine line =+    let stripped = LineParser.stripAnsi line+    in  if "[" `T.isPrefixOf` stripped+            then case TextParser.run Load.textParser stripped of+                Just (Load.Loading l) -> Just l+                _ -> Nothing+            else+                Nothing+++-- ---------------------------------------------------------------------------+-- Pure utilities (unchanged)+-- ---------------------------------------------------------------------------++-- | Make an absolute path relative to the given base directory, prefixed with @"./"@.+-- Paths already relative, or absolute paths outside @base@, are returned unchanged.+toRelative :: FilePath -> FilePath -> FilePath+toRelative base path+    | not (isAbsolute path) = path+    | otherwise = case splitDirectories (makeRelative base path) of+        (".." : _) -> path+        rel -> "." </> List.foldr1 (</>) rel+++-- | Make a relative path absolute by prepending the given base directory.+-- Paths already absolute are returned unchanged.+toAbsolute :: FilePath -> FilePath -> FilePath+toAbsolute base path+    | isAbsolute path = path+    | otherwise = base </> path+++-- | Assemble a 'LoadResult' from a project root, parsed reload items, the+-- @:show modules@ output, and the @:show targets@ output.+collectResultCustom :: FilePath -> [Load] -> [(Text, FilePath)] -> [Text] -> LoadResult+collectResultCustom projectRoot loads modules targets =+    let rel = toRelative projectRoot+        abs' = toAbsolute projectRoot+        compiledFiles = case [l.sourceFile | Load.Loading l <- loads] of+            [] -> Set.fromList (map (rel . snd) modules)+            fs -> Set.fromList (map rel fs)+        mkEntry (mn, fp) =+            ( normalise (abs' fp)+            , LoadedModule {relPath = rel fp, moduleName = mn}+            )+    in  LoadResult+            { moduleCount = length modules+            , compiledFiles+            , loadedModules = Map.fromList (map mkEntry modules)+            , targetNames = targets+            , diagnostics = toDiagnostics rel loads+            }+++-- | Assemble a 'LoadResult' from the raw reload output plus the @:show+-- modules@ / @:show targets@ output.+--+-- This is 'collectResultCustom' wrapped with a safety net: if GHCi's reload+-- ended in a @Failed,@ summary but no error diagnostic was captured (e.g. a+-- location-less failure we don't otherwise attribute to a file), a synthetic+-- error diagnostic is appended so the build can never be reported as clean+-- while GHCi considers it failed.+collectResult :: FilePath -> [Text] -> [(Text, FilePath)] -> [Text] -> LoadResult+collectResult projectRoot reloadLines modules targets =+    let loads = parseReload reloadLines+        base = collectResultCustom projectRoot loads modules targets+        hasError = any (\d -> d.severity == SError) base.diagnostics+    in  if reloadFailed loads && not hasError+            then+                base {diagnostics = base.diagnostics ++ [Diagnostic.unattributedFailure]}+            else+                base+++-- | Whether GHCi's load ended in failure, decided from the 'GSummary' items+-- 'parseReload' produces — the single place that classifies GHCi's terminal+-- @Ok, …@ / @Failed, …@ summary line.+--+-- GHCi emits the real summary last, after all compilation output. Output+-- produced /during/ the load (a Template Haskell splice, top-level IO run while+-- interpreting) can contain an earlier line that looks like a summary, so only+-- the /last/ 'GSummary' reflects the true outcome.+reloadFailed :: [Load] -> Bool+reloadFailed loads =+    case [outcome | Load.Summary outcome <- loads] of+        [] -> False+        outcomes -> List.last outcomes == LoadOutcome.LoadFailed+++toDiagnostics :: (FilePath -> FilePath) -> [Load] -> [Diagnostic]+toDiagnostics rel loads = mapMaybe toMsg loads+  where+    -- Location-less messages (e.g. @<no location info>@, @<interactive>@) carry+    -- no source span. We keep the *errors* — a @<no location info>@ error is a+    -- genuine load failure (e.g. a home-unit GHC plugin that can't be loaded in+    -- @--enable-multi-repl@) and must not be silently dropped — but discard+    -- location-less warnings, which are just noise without a file to attach to.+    toMsg (Load.Message m) | Path.isLocationLess m.file, m.severity /= Severity.Error = Nothing+    toMsg (Load.Message m) =+        Just+            Diagnostic+                { severity = case m.severity of+                    Severity.Warning -> SWarning+                    Severity.Error -> SError+                , file = rel m.file+                , line = m.startPos.line+                , col = m.startPos.col+                , endLine = m.endPos.line+                , endCol = m.endPos.col+                , title = extractTitle (map toString m.messageLines)+                , text = unlines (map toText m.messageLines)+                }+    toMsg _ = Nothing+++-- | Extract a short human-readable title from GHCi message lines.+--+-- The header line (@"file:line:col: severity: rest"@) is the first element.+-- The human-readable text is either:+--+--   * Inline, after @"error:"@ \/ @"warning:"@ in the header (old GHC style), or+--   * On subsequent indented lines (new GHC style, when header ends with a+--     diagnostic code such as @[GHC-83865]@ or @[-Wmissing-deriving-strategies]@).+--+-- Source-display lines (@"39 | ..."@, @"   | ^^^^"@) are skipped when+-- scanning body lines for content.+extractTitle :: [String] -> Text+extractTitle [] = ""+extractTitle (header : body) =+    fromMaybe (firstBodyLine body) $ inlineFromHeader $ toString $ LineParser.stripAnsi $ toText header+  where+    inlineFromHeader :: String -> Maybe Text+    inlineFromHeader h =+        let lower = map toLower h+        in  case headerAfter "error:" lower h <|> headerAfter "warning:" lower h of+                Nothing -> Nothing+                Just rest ->+                    let content = stripDiagCodes (dropWhile isSpace rest)+                    in  if null content then Nothing else Just (toText content)++    headerAfter :: String -> String -> String -> Maybe String+    headerAfter needle haystack original =+        fmap (\i -> drop (i + length needle) original)+            $ List.findIndex (needle `List.isPrefixOf`) (List.tails haystack)++    stripDiagCodes :: String -> String+    stripDiagCodes s = case dropWhile isSpace s of+        '[' : rest ->+            let after = dropWhile isSpace (drop 1 (dropWhile (/= ']') rest))+            in  stripDiagCodes after+        other -> other++    firstBodyLine :: [String] -> Text+    firstBodyLine xs =+        case [ t+             | x <- xs+             , let t = dropWhile isSpace (toString (LineParser.stripAnsi (toText x)))+             , not (null t)+             , not (isSourceLine t)+             ] of+            (t : _) -> toText t+            [] -> ""++    isSourceLine :: String -> Bool+    isSourceLine s = case dropWhile (\c -> isDigit c || c == ' ') s of+        ('|' : _) -> True+        _ -> not (null s) && all (\c -> c `elem` ("^~_ " :: String)) s
+ src/Tricorder/Daemon/Ghci/Session.hs view
@@ -0,0 +1,131 @@+module Tricorder.Daemon.Ghci.Session+    ( -- * Effect+      Session+    , with++      -- * Interpreters+    , run+    , runScripted+    )+where++import Atelier.Effects.Conc (Conc)+import Atelier.Effects.File (File)+import Atelier.Effects.Log (Log)+import Atelier.Effects.Process (Process)+import Atelier.Effects.Timeout (Timeout)+import Data.Default (def)+import Effectful+    ( Effect+    , Limit (..)+    , Persistence (..)+    , UnliftStrategy (..)+    )+import Effectful.Concurrent (Concurrent)+import Effectful.Dispatch.Dynamic+    ( interpret+    , localLift+    , localSeqLift+    , localSeqUnlift+    , localUnlift+    , reinterpret+    )+import Effectful.Exception (throwIO)+import Effectful.State.Static.Shared (State, evalState, state)+import Effectful.TH (makeEffect)++import Tricorder.Build (BuildProgress (..))+import Tricorder.Daemon.Ghci.Controls (Controls (..))+import Tricorder.Daemon.Ghci.LoadResult (LoadResult)+import Tricorder.Daemon.Ghci.Loading (Loading (..))+import Tricorder.Runtime (ProjectRoot (..))+import Tricorder.Session.Command.RenderedCommand (RenderedCommand)+import Tricorder.Session.Stage (Stage (..))++import Tricorder.Daemon.Ghci.Controls qualified as Controls+import Tricorder.Daemon.Ghci.Process qualified as Ghci.Process+import Tricorder.Daemon.Ghci.Process qualified as Process+++data Session :: Effect where+    -- | Start a new GHCi session and run the handler with that session active.+    -- The handler is also provided an action to reload the GHCi session,+    -- returning new messages with module counts. The GHCi session is closed+    -- when the handler returns.+    With+        :: (BuildProgress -> m ())+        -- ^ Action to run when reporting progress+        -> RenderedCommand 'Build+        -> ProjectRoot+        -> (LoadResult -> Controls m -> m a)+        -> Session m a+++makeEffect ''Session+++-- | Scripted interpreter for testing.+--+-- Each call to 'startGhci' or 'reloadGhci' pops the next result from the+-- pre-loaded list. 'Left' results are re-thrown as exceptions, simulating+-- GHCi crashes. 'stopGhci' is always a no-op.+runScripted+    :: forall es a. [Either SomeException LoadResult] -> Eff (Session : es) a -> Eff es a+runScripted results = reinterpret (evalState results) $ \env ->+    let popResult :: Eff (State [Either SomeException LoadResult] : es) LoadResult+        popResult = do+            x <- state \case+                x : xs -> (x, xs)+                [] -> error "GhciSessionScripted: no more results in queue"+            case x of+                Left ex -> throwIO ex+                Right r -> pure r+    in  \case+            With _ _ _ handler -> do+                initial <- popResult+                localSeqLift env \liftEff ->+                    localSeqUnlift env \unlift ->+                        unlift+                            $ handler+                                initial+                                Controls+                                    { reload = liftEff popResult+                                    , interrupt = pure ()+                                    , add = \_ -> liftEff popResult+                                    , unadd = \_ -> liftEff popResult+                                    }+++-- | GHCi session manager backed by 'Tricorder.Daemon.GhciSession.GhciProcess'+-- and 'Tricorder.Daemon.GhciSession.GhciParser'.+run+    :: ( Conc :> es+       , Concurrent :> es+       , File :> es+       , Log :> es+       , Process :> es+       , Timeout :> es+       )+    => Eff (Session : es) a -> Eff es a+run = interpret $ \env -> \case+    With onProgress cmd (ProjectRoot dir) handler -> do+        localLift env (ConcUnlift Persistent Unlimited) \liftEff ->+            localUnlift env (ConcUnlift Persistent Unlimited) \unlift -> do+                let reportProgress loading =+                        unlift+                            $ onProgress+                            $ BuildProgress+                                { compiled = loading.index+                                , total = loading.total+                                }+                Process.with def cmd dir reportProgress (\_ -> pure ()) \process startupLines -> do+                    initialResult <- Ghci.Process.collectResult process startupLines dir+                    unlift+                        $ handler initialResult+                        $ Controls.lift liftEff+                        $ Controls+                            { reload = Ghci.Process.reload process dir reportProgress+                            , interrupt = Ghci.Process.interruptGhci process+                            , add = \fp -> Ghci.Process.add process fp dir reportProgress+                            , unadd = \mn -> Ghci.Process.unadd process mn dir reportProgress+                            }
+ src/Tricorder/Daemon/Ghci/Session/State.hs view
@@ -0,0 +1,5 @@+module Tricorder.Daemon.Ghci.Session.State (State (..)) where+++data State = Idle Int | Busy Int+    deriving stock (Eq, Show)
+ src/Tricorder/Daemon/Ghci/Severity.hs view
@@ -0,0 +1,20 @@+module Tricorder.Daemon.Ghci.Severity+    ( Severity (..)+    , parse+    )+where++import Data.Text qualified as T+++-- | Severity of a GHCi diagnostic message.+data Severity = Warning | Error+    deriving stock (Eq, Ord, Show)+++-- | Determine severity from the text after the position (lowercased, stripped).+parse :: Text -> Maybe Severity+parse lower+    | "warning:" `T.isPrefixOf` lower = Just Warning+    | "error:" `T.isPrefixOf` lower = Just Error+    | otherwise = Nothing
+ src/Tricorder/Daemon/Ghci/TextParser.hs view
@@ -0,0 +1,19 @@+module Tricorder.Daemon.Ghci.TextParser+    ( TextParser+    , run+    )+where++import Text.Megaparsec (Parsec, parse)+import Prelude hiding (many)+++-- | Parser over a single 'Text' value.+type TextParser = Parsec Void Text+++-- | Run a 'TextParser' on a 'Text' value, returning 'Nothing' on failure.+run :: TextParser a -> Text -> Maybe a+run p t = case parse p "" t of+    Right x -> Just x+    Left _ -> Nothing
− src/Tricorder/Daemon/GhciSession.hs
@@ -1,175 +0,0 @@-module Tricorder.Daemon.GhciSession-    ( -- * Effect-      GhciSession-    , Controls (..)-    , transformControls-    , withGhciWith-    , withGhci--      -- * Types-    , LoadResult (..)-    , LoadedModule (..)--      -- * Interpreters-    , runGhciSession-    , runGhciSessionScripted-    )-where--import Atelier.Effects.Conc (Conc)-import Atelier.Effects.File (File)-import Atelier.Effects.Log (Log)-import Atelier.Effects.Process (Process)-import Atelier.Effects.Publishing.Pub (Pub)-import Atelier.Effects.Timeout (Timeout)-import Data.Default (def)-import Effectful-    ( Effect-    , Limit (..)-    , Persistence (..)-    , UnliftStrategy (..)-    )-import Effectful.Concurrent (Concurrent)-import Effectful.Dispatch.Dynamic-    ( interpret-    , localLift-    , localSeqLift-    , localSeqUnlift-    , localUnlift-    , reinterpret-    )-import Effectful.Exception (throwIO)-import Effectful.State.Static.Shared (State, evalState, state)-import Effectful.TH (makeEffect)--import Atelier.Effects.Publishing.Pub qualified as Pub--import Tricorder.Build (BuildProgress (..))-import Tricorder.Daemon.GhciSession.GhciParser-    ( GhciLoading (..)-    , LoadResult (..)-    , LoadedModule (..)-    )-import Tricorder.Daemon.GhciSession.GhciProcess-    ( addGhci-    , collectGhciResult-    , interruptGhci-    , reloadGhci-    , unaddGhci-    , withGhciProcess-    )-import Tricorder.Runtime (ProjectRoot (..))-import Tricorder.Session.Command.RenderedCommand (RenderedCommand)-import Tricorder.Session.Stage (Stage (..))---data GhciSession :: Effect where-    -- | Start a new GHCi session and run the handler with that session active.-    -- The handler is also provided an action to reload the GHCi session,-    -- returning new messages with module counts. The GHCi session is closed-    -- when the handler returns.-    WithGhciWith-        :: (BuildProgress -> m ())-        -- ^ Action to run when reporting progress-        -> RenderedCommand 'Build-        -> ProjectRoot-        -> (LoadResult -> Controls m -> m a)-        -> GhciSession m a---data Controls m = Controls-    { reload :: m LoadResult-    , interrupt :: m ()-    , add :: FilePath -> m LoadResult-    , unadd :: Text -> m LoadResult-    }---makeEffect ''GhciSession---transformControls :: (forall a. m a -> n a) -> Controls m -> Controls n-transformControls f ctrls =-    Controls-        { reload = f ctrls.reload-        , interrupt = f ctrls.interrupt-        , add = f . ctrls.add-        , unadd = f . ctrls.unadd-        }---withGhci-    :: (GhciSession :> es, Pub BuildProgress :> es)-    => RenderedCommand 'Build-    -> ProjectRoot-    -> (LoadResult -> Controls (Eff es) -> Eff es a)-    -> Eff es a-withGhci cmd root handler = do-    withGhciWith Pub.publish cmd root handler----- | Scripted interpreter for testing.------ Each call to 'startGhci' or 'reloadGhci' pops the next result from the--- pre-loaded list. 'Left' results are re-thrown as exceptions, simulating--- GHCi crashes. 'stopGhci' is always a no-op.-runGhciSessionScripted-    :: forall es a. [Either SomeException LoadResult] -> Eff (GhciSession : es) a -> Eff es a-runGhciSessionScripted results = reinterpret (evalState results) $ \env ->-    let popResult :: Eff (State [Either SomeException LoadResult] : es) LoadResult-        popResult = do-            x <- state \case-                x : xs -> (x, xs)-                [] -> error "GhciSessionScripted: no more results in queue"-            case x of-                Left ex -> throwIO ex-                Right r -> pure r-    in  \case-            WithGhciWith _ _ _ handler -> do-                initial <- popResult-                localSeqLift env \liftEff ->-                    localSeqUnlift env \unlift ->-                        unlift-                            $ handler-                                initial-                                Controls-                                    { reload = liftEff popResult-                                    , interrupt = pure ()-                                    , add = \_ -> liftEff popResult-                                    , unadd = \_ -> liftEff popResult-                                    }----- | GHCi session manager backed by 'Tricorder.Daemon.GhciSession.GhciProcess'--- and 'Tricorder.Daemon.GhciSession.GhciParser'.-runGhciSession-    :: ( Conc :> es-       , Concurrent :> es-       , File :> es-       , Log :> es-       , Process :> es-       , Timeout :> es-       )-    => Eff (GhciSession : es) a -> Eff es a-runGhciSession = interpret $ \env -> \case-    WithGhciWith onProgress cmd (ProjectRoot dir) handler -> do-        localLift env (ConcUnlift Persistent Unlimited) \liftEff ->-            localUnlift env (ConcUnlift Persistent Unlimited) \unlift -> do-                let reportProgress loading =-                        unlift-                            $ onProgress-                            $ BuildProgress-                                { compiled = loading.index-                                , total = loading.total-                                }-                withGhciProcess def cmd dir reportProgress (\_ -> pure ()) \process startupLines -> do-                    initialResult <- collectGhciResult process startupLines dir-                    unlift-                        $ handler initialResult-                        $ transformControls liftEff-                        $ Controls-                            { reload = reloadGhci process dir reportProgress-                            , interrupt = interruptGhci process-                            , add = \fp -> addGhci process fp dir reportProgress-                            , unadd = \mn -> unaddGhci process mn dir reportProgress-                            }
− src/Tricorder/Daemon/GhciSession/GhciParser.hs
@@ -1,674 +0,0 @@-module Tricorder.Daemon.GhciSession.GhciParser-    ( GhciLoad (..)-    , GhciLoading (..)-    , GhciMessage (..)-    , GhciSeverity (..)-    , LoadOutcome (..)-    , LoadResult (..)-    , LoadedModule (..)-    , Position (..)-    , collectResult-    , collectResultCustom-    , isLocationLess-    , reloadFailed-    , parseProgressLine-    , parseReload-    , parseShowModules-    , parseShowTargets-    , pathSuffixesAsModuleName-    , resolveKnownTargets-    , stripAnsi-    , extractTitle-    , toAbsolute-    , toRelative-    , unattributedFailure-    )-where--import Data.Char (isAlpha, isDigit, isSpace, toLower)-import System.FilePath (dropExtension, isAbsolute, makeRelative, normalise, splitDirectories, (</>))-import Text.Megaparsec-import Text.Megaparsec.Char (char, string)-import Prelude hiding (many)--import Data.List qualified as List-import Data.Map.Strict qualified as Map-import Data.Set qualified as Set-import Data.Text qualified as T-import Text.Megaparsec.Char.Lexer qualified as L--import Tricorder.Build (Diagnostic (..), Severity (..))----- | Severity of a GHCi diagnostic message.-data GhciSeverity = GWarning | GError-    deriving stock (Eq, Ord, Show)----- | Payload for a @[N of M] Compiling Mod ( file, ... )@ line.-data GhciLoading = GhciLoading-    { index :: Int-    -- ^ N (this module's index in the compilation sequence)-    , total :: Int-    -- ^ M (total modules to compile)-    , moduleName :: Text-    , sourceFile :: FilePath-    }-    deriving stock (Eq, Show)----- | A source position (1-based line and column). @(0, 0)@ when unavailable.-data Position = Position-    { line :: Int-    , col :: Int-    }-    deriving stock (Eq, Show)----- | Payload for a compiler diagnostic (error or warning).-data GhciMessage = GhciMessage-    { severity :: GhciSeverity-    , file :: FilePath-    , startPos :: Position-    , endPos :: Position-    -- ^ equals 'startPos' when no span-    , messageLines :: [Text]-    -- ^ raw lines (with any ANSI), header first-    }-    deriving stock (Eq, Show)----- | The terminal summary GHCi emits to close a load: @Ok, N modules loaded.@--- on success or @Failed, …@ on failure.-data LoadOutcome = LoadSucceeded | LoadFailed-    deriving stock (Eq, Show)----- | A structured item from GHCi's reload output.-data GhciLoad-    = GLoading GhciLoading-    | GMessage GhciMessage-    | GLoadConfig FilePath-    | GSummary LoadOutcome-    deriving stock (Eq, Show)----- | A module currently loaded in the GHCi session.-data LoadedModule = LoadedModule-    { relPath :: FilePath-    -- ^ Path relative to the project root (e.g. @"./src/Foo.hs"@).-    , moduleName :: Text-    }-    deriving stock (Eq, Show)----- | The result of a GHCi load or reload operation.-data LoadResult = LoadResult-    { moduleCount :: Int-    , compiledFiles :: Set FilePath-    -- ^ Files compiled in this cycle (derived from 'GLoading' items).-    -- Used by the session layer to decide which files' previous diagnostics to replace vs. retain.-    , loadedModules :: Map FilePath LoadedModule-    -- ^ Map from canonical absolute path to module metadata, derived from @:show modules@ output.-    -- Lists only modules that compiled successfully this cycle — GHCi drops-    -- failed-compile modules from @:show modules@.-    , targetNames :: [Text]-    -- ^ Raw entries from @:show targets@ — typically dotted module names-    -- in @cabal repl --enable-multi-repl@. Unlike 'loadedModules' this-    -- survives failed compiles, so the Builder feeds it into-    -- @KnownTargetNames@ for the dispatcher's fallback lookup.-    , diagnostics :: [Diagnostic]-    }-    deriving stock (Eq, Show)----- ------------------------------------------------------------------------------ LineStream: a Stream instance for [Text] where Token = Text--- ------------------------------------------------------------------------------- | Wrapper so we can define Stream / VisualStream / TraversableStream for---   a list of 'Text' lines without orphan-instance conflicts.-newtype LineStream = LineStream [Text]---instance Stream LineStream where-    type Token LineStream = Text-    type Tokens LineStream = [Text]-    tokenToChunk Proxy = pure-    tokensToChunk Proxy = id-    chunkToTokens Proxy = id-    chunkLength Proxy = length-    chunkEmpty Proxy = null-    take1_ (LineStream []) = Nothing-    take1_ (LineStream (t : ts)) = Just (t, LineStream ts)-    takeN_ n (LineStream s)-        | n <= 0 = Just ([], LineStream s)-        | null s = Nothing-        | otherwise = let (a, b) = splitAt n s in Just (a, LineStream b)-    takeWhile_ p (LineStream s) =-        let (a, b) = span p s in (a, LineStream b)----- ------------------------------------------------------------------------------ Parser type aliases--- ------------------------------------------------------------------------------- | Parser over a stream of 'Text' lines.-type LineParser = Parsec Void LineStream----- | Parser over a single 'Text' value.-type TextParser = Parsec Void Text----- ------------------------------------------------------------------------------ Text-level sub-parsers (helpers for diagnostic header parsing)--- ------------------------------------------------------------------------------- | Run a 'TextParser' on a 'Text' value, returning 'Nothing' on failure.-runTP :: TextParser a -> Text -> Maybe a-runTP p t = case parse p "" t of-    Right x -> Just x-    Left _ -> Nothing----- | Parse a GHCi position in one of the formats:---   @(L1,C1)-(L2,C2):@, @L:C:@, or @L:C-C2:@.--- Consumes the trailing colon.-positionP :: TextParser (Position, Position)-positionP = parenForm <|> simpleForm-  where-    parenForm = do-        _ <- char '('-        l1 <- L.decimal-        _ <- char ','-        c1 <- L.decimal-        _ <- char ')'-        _ <- char '-'-        _ <- char '('-        _ <- optional (char '(') -- some GHCi versions emit "(L1,C1)-((L2,C2):"-        l2 <- L.decimal-        _ <- char ','-        c2 <- L.decimal-        _ <- char ')'-        _ <- char ':'-        pure (Position l1 c1, Position l2 c2)-    simpleForm = do-        l <- L.decimal-        _ <- char ':'-        c <- L.decimal-        choice-            [ do-                _ <- char ':'-                pure (Position l c, Position l c)-            , do-                _ <- char '-'-                c2 <- L.decimal-                _ <- char ':'-                pure (Position l c, Position l c2)-            ]----- | Parse the full diagnostic header:---   @file:pos:@ or @drive:path:pos:@ for Windows.--- Returns @(file, startPos, endPos, textAfterColon)@.-diagHeaderP :: TextParser (Text, Position, Position, Text)-diagHeaderP = do-    a <- takeWhile1P Nothing (/= ':')-    _ <- char ':'-    filePart <--        if T.length a == 1 && isAlpha (T.head a)-            then do-                pathRest <- takeWhile1P Nothing (/= ':')-                _ <- char ':'-                pure (a <> ":" <> pathRest)-            else-                pure a-    (sp, ep) <- positionP-    afterPos <- getInput-    pure (filePart, sp, ep, afterPos)----- ------------------------------------------------------------------------------ Line-level helpers--- ------------------------------------------------------------------------------- | Check if a line is a continuation of a diagnostic message body.-isMessageBody :: Text -> Bool-isMessageBody line =-    " " `T.isPrefixOf` line-        || "\t" `T.isPrefixOf` line-        || case T.break (== '|') line of-            (prefix, rest)-                | not (T.null rest) ->-                    T.all (\c -> isSpace c || isDigit c) prefix-            _ -> False----- | Consume a line whose stripped form satisfies the predicate.--- Returns @(originalLine, strippedLine)@.-satisfyStripped :: (Text -> Bool) -> LineParser (Text, Text)-satisfyStripped p = token testLine mempty-  where-    testLine line =-        let stripped = stripAnsi line-        in  if p stripped then Just (line, stripped) else Nothing----- ------------------------------------------------------------------------------ parseReload--- ------------------------------------------------------------------------------- | Parse the output of @:reload@ from GHCi into structured items.-parseReload :: [Text] -> [GhciLoad]-parseReload ls =-    case parse (catMaybes <$> many reloadItem <* eof) "" (LineStream ls) of-        Right items -> items-        Left _ -> []----- | Parse one item (or skip one line) from the reload output.-reloadItem :: LineParser (Maybe GhciLoad)-reloadItem =-    choice-        [ -- Pattern: "Loaded GHCi configuration from <path>"-          fmap Just $ do-            (_, stripped) <- satisfyStripped ("Loaded GHCi configuration from " `T.isPrefixOf`)-            let file = toString $ T.drop (T.length "Loaded GHCi configuration from ") stripped-            pure (GLoadConfig file)-        , -- Pattern: "[N of M] Compiling ..."-          do-            (_, stripped) <- satisfyStripped ("[" `T.isPrefixOf`)-            pure (runTP loadingLineP stripped)-        , -- Pattern: terminal summary line "Ok, ..." / "Failed, ..."-          fmap Just $ do-            (_, stripped) <--                satisfyStripped (\s -> "Ok, " `T.isPrefixOf` s || "Failed, " `T.isPrefixOf` s)-            pure-                $ GSummary-                $ if "Failed, " `T.isPrefixOf` stripped then LoadFailed else LoadSucceeded-        , -- Pattern: "<no location info>: error:"-          fmap Just $ do-            (origLine, _) <- satisfyStripped ("<no location info>: error:" `T.isPrefixOf`)-            body <- many (satisfy isMessageBody)-            pure-                $ GMessage-                    GhciMessage-                        { severity = GError-                        , file = "<no location info>"-                        , startPos = Position 0 0-                        , endPos = Position 0 0-                        , messageLines = origLine : body-                        }-        , -- Pattern: diagnostic "file:pos:severity: ..."-          do-            (origLine, stripped) <--                satisfyStripped-                    ( \s ->-                        not (T.null s)-                            && not (" " `T.isPrefixOf` s)-                            && not ("\t" `T.isPrefixOf` s)-                    )-            case runTP diagHeaderP stripped of-                Nothing -> pure Nothing-                Just (fileT, sp, ep, afterPos) ->-                    let lower = T.toLower (T.stripStart afterPos)-                    in  case parseSeverity lower of-                            Nothing -> pure Nothing-                            Just sev -> do-                                body <- many (satisfy isMessageBody)-                                pure-                                    $ Just-                                    $ GMessage-                                        GhciMessage-                                            { severity = sev-                                            , file = toString fileT-                                            , startPos = sp-                                            , endPos = ep-                                            , messageLines = origLine : body-                                            }-        , -- Fallback: skip any other line-          fmap (const Nothing) anySingle-        ]----- | Parse a single line as a "[N of M] Compiling …" progress event.------ Returns 'Nothing' for any line that is not a loading line. Used to stream--- progress updates as GHCi emits them, rather than parsing the whole reload--- output after the fact.-parseProgressLine :: Text -> Maybe GhciLoading-parseProgressLine line =-    let stripped = stripAnsi line-    in  if "[" `T.isPrefixOf` stripped-            then case runTP loadingLineP stripped of-                Just (GLoading l) -> Just l-                _ -> Nothing-            else-                Nothing----- | Parse a "[N of M] Compiling Mod ( file, ... )" loading line.-loadingLineP :: TextParser GhciLoad-loadingLineP = do-    _ <- char '['-    _ <- takeWhileP Nothing (== ' ')-    n <- L.decimal-    _ <- string " of "-    m <- L.decimal-    _ <- takeWhileP Nothing (== ' ')-    _ <- char ']'-    _ <- takeWhile1P Nothing (== ' ')-    _ <- string "Compiling"-    _ <- takeWhile1P Nothing (== ' ')-    modName <- takeWhile1P Nothing (not . isSpace)-    _ <- takeWhileP Nothing (/= '(')-    _ <- char '('-    _ <- takeWhileP Nothing (== ' ')-    filePath <- takeWhile1P Nothing (\c -> c /= ',' && c /= ')')-    pure-        $ GLoading-            GhciLoading-                { index = n-                , total = m-                , moduleName = modName-                , sourceFile = toString (T.stripEnd filePath)-                }----- | Determine severity from the text after the position (lowercased, stripped).-parseSeverity :: Text -> Maybe GhciSeverity-parseSeverity lower-    | "warning:" `T.isPrefixOf` lower = Just GWarning-    | "error:" `T.isPrefixOf` lower = Just GError-    | otherwise = Nothing----- ------------------------------------------------------------------------------ parseShowModules--- ------------------------------------------------------------------------------- | Parse the output of @:show modules@ into (module name, file path) pairs.-parseShowModules :: [Text] -> [(Text, FilePath)]-parseShowModules ls =-    case parse (catMaybes <$> many showModuleLine <* eof) "" (LineStream ls) of-        Right items -> items-        Left _ -> []----- | Parse or skip one line of @:show modules@ output.-showModuleLine :: LineParser (Maybe (Text, FilePath))-showModuleLine = do-    line <- anySingle-    pure $ runTP showModuleLineP (stripAnsi line)---showModuleLineP :: TextParser (Text, FilePath)-showModuleLineP = do-    modName <- takeWhile1P Nothing (not . isSpace)-    _ <- takeWhileP Nothing (/= '(')-    _ <- char '('-    _ <- char ' '-    filePath <- takeWhile1P Nothing (\c -> c /= ',' && c /= ')')-    pure (modName, toString (T.stripEnd filePath))----- ------------------------------------------------------------------------------ parseShowTargets--- ------------------------------------------------------------------------------- | Parse the output of @:show targets@.------ Each line names one target. In @cabal repl --enable-multi-repl@ these are--- dotted module names (e.g. @"Foo.Bar"@); in plain @ghci@ sessions they can--- also be file paths. A leading @*@ marks the active interactive target and--- is stripped. Blank lines are skipped.-parseShowTargets :: [Text] -> [Text]-parseShowTargets = mapMaybe parseLine-  where-    parseLine raw =-        let cleaned = T.strip (T.dropWhile (== '*') (T.strip (stripAnsi raw)))-        in  if T.null cleaned then Nothing else Just cleaned----- ------------------------------------------------------------------------------ Pure utilities (unchanged)--- ------------------------------------------------------------------------------- | Make an absolute path relative to the given base directory, prefixed with @"./"@.--- Paths already relative, or absolute paths outside @base@, are returned unchanged.-toRelative :: FilePath -> FilePath -> FilePath-toRelative base path-    | not (isAbsolute path) = path-    | otherwise = case splitDirectories (makeRelative base path) of-        (".." : _) -> path-        rel -> "." </> List.foldr1 (</>) rel----- | Make a relative path absolute by prepending the given base directory.--- Paths already absolute are returned unchanged.-toAbsolute :: FilePath -> FilePath -> FilePath-toAbsolute base path-    | isAbsolute path = path-    | otherwise = base </> path----- | Candidate dotted module names for a file path.------ We can't convert dotted target names to paths without cabal's--- source-dirs, so callers check the other direction: any uppercase-segment--- suffix of the path (with @/@ → @.@, extension dropped) is a candidate.--- E.g. @./tricorder/src/Tricorder/Version.hs@ yields candidates--- @"Tricorder.Version"@ and @"Version"@.-pathSuffixesAsModuleName :: FilePath -> [Text]-pathSuffixesAsModuleName fp =-    let segments = filter (not . null) (splitDirectories (dropExtension (normalise fp)))-        upperSegments = dropWhile (not . startsUpper) segments-        suffixes = takeWhile (not . null) (iterate (drop 1) upperSegments)-    in  [T.intercalate "." (map toText s) | s <- suffixes]-  where-    startsUpper (c : _) = c >= 'A' && c <= 'Z'-    startsUpper _ = False----- | Strip ANSI escape sequences of the form @ESC [ \<params\> \<letter\>@.-stripAnsi :: Text -> Text-stripAnsi t = case T.uncons t of-    Nothing -> t-    Just ('\ESC', rest) -> case T.uncons rest of-        Just ('[', rest') ->-            let afterParams = T.dropWhile (not . isAlpha) rest'-            in  stripAnsi (T.drop 1 afterParams)-        _ -> T.cons '\ESC' (stripAnsi rest)-    Just (c, rest) -> T.cons c (stripAnsi rest)----- | Assemble a 'LoadResult' from a project root, parsed reload items, the--- @:show modules@ output, and the @:show targets@ output.-collectResultCustom :: FilePath -> [GhciLoad] -> [(Text, FilePath)] -> [Text] -> LoadResult-collectResultCustom projectRoot loads modules targets =-    let rel = toRelative projectRoot-        abs' = toAbsolute projectRoot-        compiledFiles = case [l.sourceFile | GLoading l <- loads] of-            [] -> Set.fromList (map (rel . snd) modules)-            fs -> Set.fromList (map rel fs)-        mkEntry (mn, fp) =-            ( normalise (abs' fp)-            , LoadedModule {relPath = rel fp, moduleName = mn}-            )-    in  LoadResult-            { moduleCount = length modules-            , compiledFiles-            , loadedModules = Map.fromList (map mkEntry modules)-            , targetNames = targets-            , diagnostics = toDiagnostics rel loads-            }----- | Assemble a 'LoadResult' from the raw reload output plus the @:show--- modules@ / @:show targets@ output.------ This is 'collectResultCustom' wrapped with a safety net: if GHCi's reload--- ended in a @Failed,@ summary but no error diagnostic was captured (e.g. a--- location-less failure we don't otherwise attribute to a file), a synthetic--- error diagnostic is appended so the build can never be reported as clean--- while GHCi considers it failed.-collectResult :: FilePath -> [Text] -> [(Text, FilePath)] -> [Text] -> LoadResult-collectResult projectRoot reloadLines modules targets =-    let loads = parseReload reloadLines-        base = collectResultCustom projectRoot loads modules targets-        hasError = any (\d -> d.severity == SError) base.diagnostics-    in  if reloadFailed loads && not hasError-            then-                base {diagnostics = base.diagnostics ++ [unattributedFailure]}-            else-                base----- | Synthetic diagnostic for a failed load with no located error. Without a--- source span it cannot point anywhere, but it stops the build reading as--- clean and tells the user where to look.-unattributedFailure :: Diagnostic-unattributedFailure =-    Diagnostic-        { severity = SError-        , file = "<no location info>"-        , line = 0-        , col = 0-        , endLine = 0-        , endCol = 0-        , title = "GHCi reported a failed load with no located error"-        , text =-            "GHCi reported a failed load with no located error.\nRun `tricorder log` to see the full GHCi output.\n"-        }----- | Whether GHCi's load ended in failure, decided from the 'GSummary' items--- 'parseReload' produces — the single place that classifies GHCi's terminal--- @Ok, …@ / @Failed, …@ summary line.------ GHCi emits the real summary last, after all compilation output. Output--- produced /during/ the load (a Template Haskell splice, top-level IO run while--- interpreting) can contain an earlier line that looks like a summary, so only--- the /last/ 'GSummary' reflects the true outcome.-reloadFailed :: [GhciLoad] -> Bool-reloadFailed loads =-    case [outcome | GSummary outcome <- loads] of-        [] -> False-        outcomes -> List.last outcomes == LoadFailed----- | Whether a diagnostic file is a GHCi location-less pseudo-file — a fully--- @\<…\>@-bracketed marker such as @\<no location info\>@ or @\<interactive\>@--- rather than a real source path. These carry no source span but can still--- represent genuine build-level failures, so callers treat them specially--- (kept rather than dropped, cleared every cycle rather than retained).------ We require both the opening @\<@ and a closing @\>@ so a real (if exotic)--- path that merely starts with @\<@, e.g. @\<generated\>/Foo.hs@, is not--- mistaken for a marker.-isLocationLess :: FilePath -> Bool-isLocationLess f = "<" `List.isPrefixOf` f && ">" `List.isSuffixOf` f----- | Path↔module-name map for the next cycle: this round's @:show modules@--- plus carryover from @prev@ for targets that failed mid-session and so--- dropped out. Never-compiled targets are absent here (no path↔name--- mapping); the dispatcher recognises those via 'KnownTargetNames'.-resolveKnownTargets-    :: Map FilePath LoadedModule-    -- ^ Previous known-targets map (carryover source).-    -> LoadResult-    -> Map FilePath LoadedModule-resolveKnownTargets prev lr =-    let primary = lr.loadedModules-        primaryNames = Set.fromList [lm.moduleName | lm <- Map.elems primary]-        prevByName = Map.fromList [(lm.moduleName, (path, lm)) | (path, lm) <- Map.toList prev]-        carryover =-            Map.fromList-                [ (path, lm)-                | name <- lr.targetNames-                , not (Set.member name primaryNames)-                , Just (path, lm) <- [Map.lookup name prevByName]-                ]-    in  Map.union primary carryover---toDiagnostics :: (FilePath -> FilePath) -> [GhciLoad] -> [Diagnostic]-toDiagnostics rel loads = mapMaybe toMsg loads-  where-    -- Location-less messages (e.g. @<no location info>@, @<interactive>@) carry-    -- no source span. We keep the *errors* — a @<no location info>@ error is a-    -- genuine load failure (e.g. a home-unit GHC plugin that can't be loaded in-    -- @--enable-multi-repl@) and must not be silently dropped — but discard-    -- location-less warnings, which are just noise without a file to attach to.-    toMsg (GMessage m) | isLocationLess m.file, m.severity /= GError = Nothing-    toMsg (GMessage m) =-        Just-            Diagnostic-                { severity = case m.severity of-                    GWarning -> SWarning-                    GError -> SError-                , file = rel m.file-                , line = m.startPos.line-                , col = m.startPos.col-                , endLine = m.endPos.line-                , endCol = m.endPos.col-                , title = extractTitle (map toString m.messageLines)-                , text = unlines (map toText m.messageLines)-                }-    toMsg _ = Nothing----- | Extract a short human-readable title from GHCi message lines.------ The header line (@"file:line:col: severity: rest"@) is the first element.--- The human-readable text is either:------   * Inline, after @"error:"@ \/ @"warning:"@ in the header (old GHC style), or---   * On subsequent indented lines (new GHC style, when header ends with a---     diagnostic code such as @[GHC-83865]@ or @[-Wmissing-deriving-strategies]@).------ Source-display lines (@"39 | ..."@, @"   | ^^^^"@) are skipped when--- scanning body lines for content.-extractTitle :: [String] -> Text-extractTitle [] = ""-extractTitle (header : body) =-    fromMaybe (firstBodyLine body) (inlineFromHeader (toString (stripAnsi (toText header))))-  where-    inlineFromHeader :: String -> Maybe Text-    inlineFromHeader h =-        let lower = map toLower h-        in  case headerAfter "error:" lower h <|> headerAfter "warning:" lower h of-                Nothing -> Nothing-                Just rest ->-                    let content = stripDiagCodes (dropWhile isSpace rest)-                    in  if null content then Nothing else Just (toText content)--    headerAfter :: String -> String -> String -> Maybe String-    headerAfter needle haystack original =-        fmap (\i -> drop (i + length needle) original)-            $ List.findIndex (needle `List.isPrefixOf`) (List.tails haystack)--    stripDiagCodes :: String -> String-    stripDiagCodes s = case dropWhile isSpace s of-        '[' : rest ->-            let after = dropWhile isSpace (drop 1 (dropWhile (/= ']') rest))-            in  stripDiagCodes after-        other -> other--    firstBodyLine :: [String] -> Text-    firstBodyLine xs =-        case [ t-             | x <- xs-             , let t = dropWhile isSpace (toString (stripAnsi (toText x)))-             , not (null t)-             , not (isSourceLine t)-             ] of-            (t : _) -> toText t-            [] -> ""--    isSourceLine :: String -> Bool-    isSourceLine s = case dropWhile (\c -> isDigit c || c == ' ') s of-        ('|' : _) -> True-        _ -> not (null s) && all (\c -> c `elem` ("^~_ " :: String)) s
− src/Tricorder/Daemon/GhciSession/GhciProcess.hs
@@ -1,590 +0,0 @@-module Tricorder.Daemon.GhciSession.GhciProcess-    ( Config (..)-    , GhciProcess (..)-    , GhciProcessError (..)-    , UnexpectedExit (..)-    , SessionState (..)-    , InterruptDecision (..)-    , decideInterrupt-    , waitForBannerOrFail-    , withGhciProcess-    , execGhci-    , drainUntil-    , interruptGhci-    , terminateGhciProcess-    , collectGhciResult-    , reloadGhci-    , addGhci-    , unaddGhci-    )-where--import Atelier.Effects.Conc (Conc)-import Atelier.Effects.File (BufferMode (..), File, Handle)-import Atelier.Effects.Log (Log)-import Atelier.Effects.Process-    ( Process-    , RunningProcess-    , createPipe-    , getStderr-    , getStdin-    , getStdout-    , setStderr-    , setStdin-    , setStdout-    , setWorkingDir-    , shell-    )-import Atelier.Effects.Timeout (Timeout, timeout)-import Control.Concurrent.STM (TVar, modifyTVar', readTVar, retry, writeTVar)-import Data.Default (Default (..))-import Data.Sequence ((|>))-import Data.Time.Units (Second)-import Effectful.Concurrent (Concurrent)-import Effectful.Concurrent.STM (atomically, newTVarIO)-import Effectful.Exception (finally, throwIO, trySync)--import Atelier.Effects.Conc qualified as Conc-import Atelier.Effects.File qualified as File-import Atelier.Effects.Log qualified as Log-import Atelier.Effects.Process qualified as Process-import Data.Text qualified as T--import Tricorder.Daemon.GhciSession.GhciParser-    ( GhciLoading (..)-    , LoadResult (..)-    , collectResult-    , parseProgressLine-    , parseShowModules-    , parseShowTargets-    , stripAnsi-    , unattributedFailure-    )-import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))----- | Configuration for GHCi process management.-data Config = Config-    { startupTimeout :: Second-    -- ^ How long to wait for the GHCi version banner on startup.-    , shutdownTimeout :: Second-    -- ^ How long to wait for the process to exit gracefully before force-killing.-    , extraSetupCommands :: [Text]-    -- ^ Additional GHCi commands to send after the fixed setup block.-    -- Use this to inject session-wide options such as @:set -XOverloadedStrings@.-    }---instance Default Config where-    def =-        Config-            { startupTimeout = 60-            , shutdownTimeout = 5-            , extraSetupCommands = []-            }---data SessionState = Idle Int | Busy Int-    deriving stock (Eq, Show)----- | The outcome of consulting the 'SessionState' when an interrupt arrives.------ 'NoOpIdle' means the GHCi session is at the prompt — sending SIGINT and a--- sync marker would dirty the buffers and desync the next 'execGhci'.--- 'SendInterruptFor n' means a command was in flight (state 'Busy n') and--- the caller should SIGINT the process group and re-write @markerFor n@ (that--- command's own marker) to unblock the in-progress 'drainUntil'.-data InterruptDecision-    = NoOpIdle-    | SendInterruptFor Int-    deriving stock (Eq, Show)----- | Pure state machine for 'interruptGhci'. Returns the new 'SessionState'--- to install along with the 'InterruptDecision' the caller should act on.-decideInterrupt :: SessionState -> (SessionState, InterruptDecision)-decideInterrupt s@(Idle _) = (s, NoOpIdle)-decideInterrupt (Busy n) = (Idle (n + 1), SendInterruptFor n)----- | A handle to a running GHCi subprocess.-data GhciProcess = GhciProcess-    { stdin :: Handle-    , stdout :: Handle-    , stderr :: Handle-    , handle :: RunningProcess Handle Handle Handle-    , stateVar :: TVar SessionState-    }----- | Errors that can occur during GHCi process management.-data GhciProcessError-    = StartupTimeout-    | UnexpectedExit UnexpectedExit-    | -- | The build command exited (or printed nothing parseable) before GHCi-      -- produced its version banner. The 'Text' is the captured stderr+stdout-      -- output so callers can surface a useful error (e.g. cabal's dependency-      -- resolution failure).-      StartupFailed Text-    deriving stock (Eq, Show)---data UnexpectedExit = MkUnexpectedExit-    { marker :: Text-    , errorMessage :: Text-    }-    deriving stock (Eq, Show)---instance Exception GhciProcessError----- | Set up the GHCi protocol on an already-started process and return its--- handle together with the output captured during startup.------ The spawn and group teardown are owned by 'withGhciProcess'.-setupGhciProcess-    :: ( Conc :> es-       , Concurrent :> es-       , File :> es-       , Log :> es-       , Timeout :> es-       )-    => Config-    -> RunningProcess Handle Handle Handle-    -> (GhciLoading -> Eff es ())-    -- ^ Called as each @[N of M] Compiling …@ line is streamed during the-    -- initial-build drain, so the UI can update the progress bar live-    -- instead of replaying everything once compilation finishes.-    -> (GhciProcess -> Eff es ())-    -- ^ @onReady@. Called once the 'GhciProcess' is constructed but before the-    -- banner wait and initial-build drain — so callers can register the process-    -- for interruption while the slow @cabal repl@ startup (dependency build-    -- and recompilation) is still in progress.-    -> Eff es (GhciProcess, [Text])-setupGhciProcess config p onProgress onReady = do-    let inp = getStdin p-        out = getStdout p-        err = getStderr p-    File.hSetBuffering inp LineBuffering-    File.hSetBuffering out LineBuffering-    File.hSetBuffering err LineBuffering--    -- Register the process for interruption before the (possibly slow) banner-    -- wait, so an interrupt during @cabal repl@ startup can terminate it.-    stateVar <- newTVarIO (Idle 0)-    let ghciProcess =-            GhciProcess-                { stdin = inp-                , stdout = out-                , stderr = err-                , handle = p-                , stateVar = stateVar-                }-    onReady ghciProcess--    -- Send a blank line to kick GHCi into producing output-    File.hPutTextLn inp ""-    File.hFlush inp--    -- Wait for the version banner. We concurrently capture stderr so that if-    -- the build command exits before printing a banner (e.g. cabal's-    -- dependency resolution fails) we can surface its error output.-    waitForBannerOrFail config.startupTimeout out err--    -- Send fixed setup commands (protocol requirements)-    File.hPutTextLn inp ":set prompt \"\""-    File.hPutTextLn inp ":set prompt-cont \"\""-    -- Send any caller-supplied extra setup commands-    for_ config.extraSetupCommands \c ->-        File.hPutTextLn inp c-    File.hFlush inp--    -- Sync: drain until marker 1 seen, then set counter to 2.-    -- Capture lines from both streams — the stderr output contains the initial-    -- compilation progress and any startup diagnostics. The line hook fires-    -- 'onProgress' for each "[N of M] Compiling …" line as it arrives, so the-    -- UI can update the progress bar live during the initial build.-    let marker1 = markerFor 1-        hook = progressLineHook onProgress-    sendSyncCommand inp marker1-    initialLines <- Conc.scoped do-        stdoutThread <- Conc.fork $ drainUntil out marker1 hook-        stderrThread <- Conc.fork $ drainUntil err marker1 hook-        stdoutLines <- Conc.await stdoutThread-        stderrLines <- Conc.await stderrThread-        pure (stdoutLines ++ stderrLines)-    atomically $ writeTVar stateVar (Idle 2)--    pure (ghciProcess, initialLines)----- | Run a GHCi @cabal repl@ session for the duration of @action@.------ The session runs in its own process group, so the whole group is torn down on--- exit; 'quitGhci' first asks GHCi to @:quit@ for a graceful shutdown. The--- action receives the process handle and the output captured during startup.--- See 'setupGhciProcess' for the @onProgress@ and @onReady@ callbacks.-withGhciProcess-    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es, Process :> es, Timeout :> es)-    => Config-    -> RenderedCommand stage-    -> FilePath-    -> (GhciLoading -> Eff es ())-    -> (GhciProcess -> Eff es ())-    -> (GhciProcess -> [Text] -> Eff es a)-    -> Eff es a-withGhciProcess config cmd dir onProgress onReady action =-    Process.withProcessGroup processConfig \p -> do-        (ghciProcess, initialLines) <- setupGhciProcess config p onProgress onReady-        action ghciProcess initialLines `finally` quitGhci config ghciProcess-  where-    processConfig =-        setStdin createPipe-            $ setStdout createPipe-            $ setStderr createPipe-            $ setWorkingDir dir-            $ shell-            $ toString cmd.getRenderedCommand----- | Execute a command in GHCi and return the combined stdout+stderr output--- lines. The @onProgress@ callback fires for each @[N of M] Compiling …@ line--- as it arrives, so reload/add/unadd progress is streamed live to the UI.--- Pass @\\_ -> pure ()@ for commands that do not trigger compilation.-execGhci-    :: ( Conc :> es-       , Concurrent :> es-       , File :> es-       , Log :> es-       )-    => GhciProcess -> Text -> (GhciLoading -> Eff es ()) -> Eff es [Text]-execGhci ghciProcess command onProgress = do-    n <- atomically do-        readTVar ghciProcess.stateVar >>= \case-            Idle n -> writeTVar ghciProcess.stateVar (Busy n) $> n-            Busy _ -> retry-    doExec n `finally` atomically (writeTVar ghciProcess.stateVar (Idle (n + 1)))-  where-    doExec n = do-        let marker = markerFor n-            hook = progressLineHook onProgress-        File.hPutTextLn ghciProcess.stdin command-        File.hFlush ghciProcess.stdin-        sendSyncCommand ghciProcess.stdin marker-        -- Scoped so that an exception from one drain (e.g. 'UnexpectedExit'-        -- when the underlying process is terminated mid-command) is-        -- contained here, re-raised by 'await', and caught by the caller's-        -- 'trySync'. Without 'scoped', Ki propagates the exception to the-        -- \*ambient* scope — typically the builder's listener scope — which-        -- tears down the whole builder loop instead of just failing this-        -- one command.-        (stdoutLines, stderrLines) <- Conc.scoped do-            stdoutThread <- Conc.fork $ drainUntil ghciProcess.stdout marker hook-            stderrThread <- Conc.fork $ drainUntil ghciProcess.stderr marker hook-            (,) <$> Conc.await stdoutThread <*> Conc.await stderrThread-        pure (stdoutLines ++ stderrLines)----- | Interrupt the currently running GHCi command (if any).------ If a command is in progress (state 'Busy n'), sends SIGINT to the GHCi--- process group and re-writes /that command's own/ sync marker (@markerFor n@)--- so the in-progress 'drainUntil', which is waiting for exactly that marker,--- unblocks. The next command runs as @n + 1@ and waits for @markerFor (n + 1)@,--- so any duplicate @markerFor n@ left in the buffers is skipped by 'drainUntil'--- rather than mistaken for the next command's marker.------ When GHCi is 'Idle' this is a true no-op: sending SIGINT and a sync marker to--- an idle GHCi leaves leftover marker output in the buffers.-interruptGhci :: (Concurrent :> es, File :> es, Process :> es) => GhciProcess -> Eff es ()-interruptGhci ghciProcess = do-    decision <- atomically do-        s <- readTVar ghciProcess.stateVar-        let (s', d) = decideInterrupt s-        writeTVar ghciProcess.stateVar s'-        pure d-    case decision of-        NoOpIdle -> pure ()-        SendInterruptFor n -> do-            Process.interruptProcessGroup ghciProcess.handle-            sendSyncCommand ghciProcess.stdin (markerFor n)----- | Forcefully terminate a GHCi process and its whole group.------ Stronger than 'interruptGhci': intended for one-shot processes (such as the--- per-suite @cabal repl test:…@ used by the test runner) where SIGINT is--- insufficient — test frameworks like @hspec@ and @tasty@ install SIGINT--- handlers that finalise the current run rather than aborting it. Safe to call--- from another thread while the session is running.-terminateGhciProcess :: (Process :> es) => GhciProcess -> Eff es ()-terminateGhciProcess ghciProcess =-    Process.terminateProcessGroup ghciProcess.handle----- | Ask GHCi to @:quit@ and wait briefly for it to exit cleanly, letting it--- shut down gracefully before the enclosing 'withGhciProcess' tears down the--- group. Never throws.-quitGhci :: (File :> es, Process :> es, Timeout :> es) => Config -> GhciProcess -> Eff es ()-quitGhci config ghciProcess = do-    void $ trySync $ do-        File.hPutTextLn ghciProcess.stdin ":quit"-        File.hFlush ghciProcess.stdin-    void $ timeout config.shutdownTimeout (Process.waitExitCode ghciProcess.handle)----- ------------------------------------------------------------------------------ Internal helpers---- | Build the finish marker for counter value @n@.-markerFor :: Int -> Text-markerFor n = markerPrefix <> show n <> "~#"----- | The prefix shared by all finish markers.-markerPrefix :: Text-markerPrefix = "#~TRI-FINISH-"----- | Write the synchronisation Haskell statements to GHCi stdin.------ After each user command, these cause GHCi to emit the finish marker on both--- stdout and stderr, so 'drainUntil' knows when to stop.------ The marker is emitted as two standalone, fully-qualified statements — one per--- stream — using only 'System.IO.hPutStrLn'. This is deliberate: a--- SIGINT-interrupted ':reload' empties GHCi's interactive scope, dropping the--- implicit @import Prelude@, so bare names like @putStrLn@ (and even the @>>@--- operator) fall out of scope. A marker built from those would /error/ instead--- of printing — its marker would never appear and 'drainUntil' would block--- forever waiting for it (the "stuck Building…" stall). Fully-qualified--- 'System.IO.hPutStrLn' resolves via GHCi's implicit qualified imports even--- with an emptied scope, so the marker survives an interrupt.-sendSyncCommand :: (File :> es) => Handle -> Text -> Eff es ()-sendSyncCommand h marker = do-    -- Use show to produce a valid Haskell string literal for the marker text.-    let markerLit = toText (show @String (toString marker)) -- e.g. "\"#~TRI-FINISH-3~#\""-    File.hPutTextLn h ("System.IO.hPutStrLn System.IO.stdout " <> markerLit)-    File.hPutTextLn h ("System.IO.hPutStrLn System.IO.stderr " <> markerLit)-    File.hFlush h----- | Read lines from a handle until /this command's/ finish marker is seen (or--- EOF).------ Stops only on a line containing the exact @marker@ it was given. A line--- carrying a /different/ finish marker — a stale leftover from a prior command--- that was interrupted mid-flight — is skipped rather than matched, so it can--- never make a later drain return prematurely (the "0 modules"/hang desync).--- Each ordinary line is passed to @onLine@ as it arrives, so callers can stream--- progress without waiting for the full drain to complete. Returns accumulated--- non-marker lines in order. Throws 'UnexpectedExit' on EOF before the marker.-drainUntil :: (File :> es, Log :> es) => Handle -> Text -> (Text -> Eff es ()) -> Eff es [Text]-drainUntil h marker onLine = go mempty-  where-    go acc = do-        result <- trySync $ File.hGetLine h-        case result of-            Left ex -> do-                let accumulatedLines = T.intercalate "\n" $ toList acc-                    errorMessage =-                        T.intercalate-                            "\n"-                            [ "Reached EOF before reading marker from GHCi."-                            , "Was looking for marker '" <> marker <> "', but no such marker was found."-                            , ""-                            ]-                            <> if T.null accumulatedLines-                                then-                                    T.intercalate-                                        "\n"-                                        [ "GHCi returned no output before we reached what we believe is EOF."-                                        , "Got the following exception when attempting to read from GHCi:"-                                        , toText $ displayException ex-                                        ]-                                else-                                    T.intercalate-                                        "\n"-                                        [ "Accumulated output from GHCi so far:"-                                        , accumulatedLines-                                        ]-                Log.err errorMessage-                throwIO-                    $ UnexpectedExit-                    $ MkUnexpectedExit {marker, errorMessage}-            Right line-                | marker `T.isInfixOf` line -> pure $ toList acc-                -- A stale marker from an interrupted command: drop it, keep going.-                | markerPrefix `T.isInfixOf` line -> go acc-                | otherwise -> do-                    onLine line-                    go $ acc |> line----- | Convert a 'GhciLoading' progress callback into a per-line hook suitable--- for 'drainUntil'. Non-progress lines are ignored.-progressLineHook :: (GhciLoading -> Eff es ()) -> Text -> Eff es ()-progressLineHook onProgress line = traverse_ onProgress (parseProgressLine line)----- | Wait up to the given number of seconds for a GHCi version banner on--- stdout, capturing stderr (and any non-banner stdout lines) in case the--- build command fails before printing a banner.------ Throws 'StartupFailed' with the captured output if stdout EOFs (the build--- command exited) or 'StartupTimeout' if the banner never arrives.-waitForBannerOrFail-    :: ( Conc :> es-       , Concurrent :> es-       , File :> es-       , Timeout :> es-       )-    => Second -> Handle -> Handle -> Eff es ()-waitForBannerOrFail delay out err = do-    capturedVar <- newTVarIO ([] :: [Text])-    let captureLine line = atomically $ modifyTVar' capturedVar (line :)-        drainStderr = drainUntilEof err captureLine-        watchStdout = waitForBannerStdout out captureLine--    Conc.scoped do-        stderrThread <- Conc.fork drainStderr-        result <- timeout delay $ trySync watchStdout-        case result of-            Just (Right ()) -> pure ()-            Just (Left ex) -> do-                -- stdout EOF before the banner: the build command exited. Wait-                -- for the stderr drain to reach EOF too (bounded, in case the-                -- pipe lingers) so we surface its *complete* output — e.g.-                -- cabal's dependency-resolution error — rather than whatever-                -- the drain happened to have read so far.-                _ <- timeout (1 :: Second) (Conc.await stderrThread)-                captured <- atomically $ readTVar capturedVar-                throwIO $ StartupFailed $ fromMaybe (startupExitedMessage ex) (renderCapturedLines captured)-            Nothing -> do-                captured <- atomically $ readTVar capturedVar-                throwIO $ maybe StartupTimeout StartupFailed (renderCapturedLines captured)---waitForBannerStdout :: (File :> es) => Handle -> (Text -> Eff es ()) -> Eff es ()-waitForBannerStdout h captureLine = go-  where-    isVersionLine :: Text -> Bool-    isVersionLine line =-        let stripped = stripAnsi line-        in  "GHCi, version " `T.isInfixOf` stripped-                || "GHCJSi, version " `T.isInfixOf` stripped-                || "Clashi, version " `T.isInfixOf` stripped--    go = do-        result <- trySync $ File.hGetLine h-        case result of-            Left ex -> throwIO ex-            Right line ->-                if isVersionLine line-                    then-                        pure ()-                    else do-                        captureLine line-                        go---drainUntilEof :: (File :> es) => Handle -> (Text -> Eff es ()) -> Eff es ()-drainUntilEof h onLine = go-  where-    go = do-        result <- trySync $ File.hGetLine h-        case result of-            Left _ -> pure ()-            Right line -> onLine line >> go----- | Render the captured (reverse-order) output lines into an error message,--- stripping ANSI escapes and dropping blank lines. Returns 'Nothing' when--- nothing useful remains, so callers can fall back to a generic message.-renderCapturedLines :: [Text] -> Maybe Text-renderCapturedLines capturedRev =-    let cleaned = filter (not . T.null . T.strip) (map stripAnsi (reverse capturedRev))-    in  if null cleaned then Nothing else Just (T.intercalate "\n" cleaned)----- | Fallback message when the build command exited before the banner without--- printing anything we could capture.-startupExitedMessage :: SomeException -> Text-startupExitedMessage ex =-    "Build command exited before GHCi started: " <> toText (displayException ex)----- | Parse already-drained GHCi output lines into a 'LoadResult', fetching the--- current module list via @:show modules@.------ Progress is emitted live by 'drainUntil' as lines arrive, so no replay--- callback is needed here — this function only assembles the final result.-collectGhciResult-    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)-    => GhciProcess-    -> [Text]-    -> FilePath-    -> Eff es LoadResult-collectGhciResult process lines' projectRoot = do-    let noProgress = \_ -> pure ()-    moduleLines <- execGhci process ":show modules" noProgress-    targetLines <- execGhci process ":show targets" noProgress-    let result =-            collectResult-                projectRoot-                lines'-                (parseShowModules moduleLines)-                (parseShowTargets targetLines)-    -- A failed load with no located error produces only the synthetic-    -- 'unattributedFailure'. The parsed diagnostics tell the user nothing in-    -- that case, so log the raw GHCi output — it's the only way to see what-    -- actually went wrong, and the synthetic diagnostic points here.-    when (any (== unattributedFailure) result.diagnostics)-        $ Log.info-        $ "GHCi reported a failed load with no located error. Full GHCi output:\n"-            <> T.unlines lines'-    pure result----- | Execute @:reload@ and return the assembled 'LoadResult'. Progress events--- fire live via @onProgress@ as each @[N of M] Compiling …@ line is read.-reloadGhci-    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)-    => GhciProcess-    -> FilePath-    -> (GhciLoading -> Eff es ())-    -> Eff es LoadResult-reloadGhci process projectRoot onProgress = do-    reloadLines <- execGhci process ":reload" onProgress-    collectGhciResult process reloadLines projectRoot----- | Execute @:add@ for the given file and return the assembled 'LoadResult'.--- Progress events fire live via @onProgress@ as compilation proceeds.-addGhci-    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)-    => GhciProcess-    -> FilePath -- the file to :add-    -> FilePath -- projectRoot-    -> (GhciLoading -> Eff es ())-    -> Eff es LoadResult-addGhci process filePath projectRoot onProgress = do-    addLines <- execGhci process (":add " <> T.pack filePath) onProgress-    collectGhciResult process addLines projectRoot----- | Execute @:unadd@ for the given module and return the assembled--- 'LoadResult'. Progress events fire live via @onProgress@ as compilation--- proceeds.-unaddGhci-    :: (Conc :> es, Concurrent :> es, File :> es, Log :> es)-    => GhciProcess-    -> Text -- the module name to :unadd-    -> FilePath -- projectRoot-    -> (GhciLoading -> Eff es ())-    -> Eff es LoadResult-unaddGhci process moduleName projectRoot onProgress = do-    unaddLines <- execGhci process (":unadd " <> moduleName) onProgress-    collectGhciResult process unaddLines projectRoot
+ src/Tricorder/Daemon/HookDispatch.hs view
@@ -0,0 +1,34 @@+module Tricorder.Daemon.HookDispatch (main) where++import Atelier.Effects.FileWatcher (FileEvent (..))+import Atelier.Effects.Process (Process)+import Atelier.Effects.PubSub.Sub (Sub)+import Effectful.Reader.Static (Reader)++import Atelier.Effects.PubSub.Sub qualified as Sub+import System.FilePath qualified as Path++import Tricorder.Daemon.Watch (WatchedFile (..))+import Tricorder.Runtime (ProjectRoot)+import Tricorder.Session (Session (..))+import Tricorder.Session.HaskellHooks (HaskellHooks (..))+import Tricorder.Session.Hooks (Hooks (..))++import Tricorder.Session.Hook qualified as Hook+++main :: (Process :> es, Reader ProjectRoot :> es, Sub WatchedFile :> es) => Session -> Eff es Void+main session =+    Sub.listen_ \case+        (WatchedFile path event)+            | Path.takeExtension path == "hs"+            , Just hooks <- session.hooks.haskell ->+                runHsHook hooks event+            | otherwise -> pure ()+++runHsHook :: (Process :> es, Reader ProjectRoot :> es) => HaskellHooks -> FileEvent -> Eff es ()+runHsHook hooks = \case+    Added -> whenJust hooks.fileAdded Hook.run+    Modified -> whenJust hooks.fileModified Hook.run+    Removed -> whenJust hooks.fileRemoved Hook.run
src/Tricorder/Daemon/Hpack.hs view
@@ -6,20 +6,22 @@ import Atelier.Effects.Debounce (Debounce) import Atelier.Effects.FileWatcher (FileWatcher) import Atelier.Effects.Log (Log)-import Atelier.Effects.Publishing (runPubSub)-import Atelier.Effects.Publishing.Pub (Pub)+import Atelier.Effects.PubSub.Pub (Pub) import Effectful.Reader.Static (Reader)  import Atelier.Effects.Conc qualified as Conc import Atelier.Effects.FileWatcher qualified as FileWatcher import Atelier.Effects.Log qualified as Log-import Atelier.Effects.Publishing.Pub qualified as Pub-import Atelier.Effects.Publishing.Sub qualified as Sub+import Atelier.Effects.PubSub qualified as PubSub+import Atelier.Effects.PubSub.Pub qualified as Pub+import Atelier.Effects.PubSub.Sub qualified as Sub import Data.List qualified as List import Effectful.Reader.Static qualified as Reader  import Tricorder.Daemon.Hpack.Effect (Hpack) import Tricorder.Runtime (ProjectRoot (..))+import Tricorder.Session (Session (..))+import Tricorder.Session.GenerateWithHpack (GenerateWithHpack (..))  import Tricorder.Daemon.Hpack.Effect qualified as Hpack @@ -34,13 +36,14 @@        , Log :> es        , Reader ProjectRoot :> es        )-    => Eff es ()-main = runPubSub @RePack $ Conc.scoped do-    hpackInPath <- Hpack.hpackIsInPath-    when hpackInPath do-        projectRoot <- Reader.ask-        Conc.fork_ $ Sub.listen_ \(RePack path) -> runHpack path-        vacuous $ watchPackageYaml projectRoot+    => Session -> Eff es ()+main session = PubSub.run @RePack $ Conc.scoped do+    when (coerce session.generateWithHpack) do+        hpackInPath <- Hpack.hpackIsInPath+        when hpackInPath do+            projectRoot <- Reader.ask+            Conc.fork_ $ Sub.listen_ \(RePack path) -> runHpack path+            vacuous $ watchPackageYaml projectRoot   runHpack :: (Hpack :> es, Log :> es) => FilePath -> Eff es ()
src/Tricorder/Daemon/Main.hs view
@@ -1,6 +1,5 @@ module Tricorder.Daemon.Main (main) where -import Atelier.Effects.Cache (runCacheTtl) import Atelier.Effects.Chan (runChan) import Atelier.Effects.Clock (runClock) import Atelier.Effects.Conc (runConc)@@ -12,52 +11,53 @@ import Atelier.Effects.FileSystem (runFileSystemIO) import Atelier.Effects.FileWatcher (runFileWatcherIO) import Atelier.Effects.Process (runProcessIO)-import Atelier.Effects.Publishing (runPubSub) import Atelier.Effects.Timeout (runTimeout) import Atelier.Signal (installTerminationHandler) import Data.Default (def) import Effectful (runEff) import Effectful.Concurrent (runConcurrent)-import Effectful.Reader.Static (runReader)-import Effectful.State.Static.Shared (evalState)-import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery) -import Atelier.Effects.Cache.Config qualified as CacheConfig import Atelier.Effects.Conc qualified as Conc import Atelier.Effects.FileSystem.Glob qualified as Glob import Atelier.Effects.Input qualified as Input import Atelier.Effects.Log qualified as Log+import Atelier.Effects.PubSub qualified as PubSub+import Atelier.Effects.PubSub.Pub qualified as Pub+import Atelier.Effects.PubSub.Sub qualified as Sub+import Effectful.Reader.Static qualified as Reader+import Effectful.State.Static.Shared qualified as State  import Tricorder.Build (BuildId (..), BuildPhase)-import Tricorder.Config (inputLoadedConfig)-import Tricorder.Daemon.GhciSession (runGhciSession)+import Tricorder.Build.Changes (CabalChangeDetected, SourceChangeDetected)+import Tricorder.Config (asInput)+import Tricorder.Daemon.Watch (WatchedFile) import Tricorder.Logging (runLogging) import Tricorder.Runtime (runLogPath, runProjectRoot, runRuntimeDir, runSocketPath)-import Tricorder.Session (inputSession) import Tricorder.Session.CabalFile (inputCabalFiles)-import Tricorder.Session.IdleTimeout (IdleTimeout)-import Tricorder.Session.Repl (Repl)-import Tricorder.Socket.UnixSocket (runUnixSocketIO) import Tricorder.SourceLookup.GhcPkg (runGhcPkgIO)-import Tricorder.SourceLookup.PackageId (PackageId) +import Tricorder.Build qualified as BuildPhase+import Tricorder.Config.LoadedConfig qualified as LoadedConfig import Tricorder.Daemon.Core qualified as Core import Tricorder.Daemon.EvalCommentRunner qualified as EvalCommentRunner+import Tricorder.Daemon.Ghci.Session qualified as Ghci.Session+import Tricorder.Daemon.HookDispatch qualified as HookDispatch+import Tricorder.Daemon.Hpack qualified as Hpack import Tricorder.Daemon.Hpack.Effect qualified as Hpack import Tricorder.Daemon.IdleTimer qualified as IdleTimer import Tricorder.Daemon.TestRunner qualified as TestRunner-import Tricorder.Session.Repl qualified as Repl+import Tricorder.Daemon.Watch qualified as Watch+import Tricorder.Session qualified as Session import Tricorder.Session.StackProject qualified as StackProject+import Tricorder.Session.State qualified as Session.State+import Tricorder.Session.State.Invalid qualified as Session.State.Result.Invalid+import Tricorder.Session.State.Result qualified as Session.State.Result import Tricorder.Socket.Server qualified as Server-import Tricorder.SourceLookup qualified as SourceLookup-import Tricorder.SourceLookup.Hackage qualified as Hackage-import Tricorder.SourceLookup.PackageStore qualified as PackageStore+import Tricorder.Socket.UnixSocket qualified as UnixSocket import Tricorder.Version qualified as Version import Tricorder.Waiters qualified as Waiters  --- | Run the daemon for the given project root.--- Blocks forever; all work happens inside the component system. main :: IO () main =     runEff@@ -77,36 +77,51 @@         . runSocketPath         . runLogPath         . runEnv-        . inputLoadedConfig+        . LoadedConfig.asInput         . runLogging         . runChan         . StackProject.inputWithCache         . inputCabalFiles-        . inputSession-        . runReader @CacheConfig.Config def-        . runCacheTtl @ModuleName @PackageId-        . runCacheTtl @(PackageId, SourceQuery) @SourceLookup.ModuleSourceResult         . runProcessIO         . runGhcPkgIO-        . runUnixSocketIO-        . runGhciSession-        . evalState (BuildId 1)+        . UnixSocket.runIO+        . Ghci.Session.run+        . State.evalState (BuildId 1)         . Input.fromState @BuildId-        . evalState Repl.Unknown-        . Input.fromState @Repl-        . evalState @IdleTimeout def-        . Input.fromState @IdleTimeout-        . IdleTimer.quitOnTimeout-        . runPubSub @BuildPhase         . Hpack.run-        . Hackage.run-        . PackageStore.run         . EvalCommentRunner.run         . TestRunner.run         . Waiters.run+        . PubSub.run @BuildPhase+        . PubSub.run @CabalChangeDetected+        . PubSub.run @SourceChangeDetected+        . PubSub.run @WatchedFile+        . Tricorder.Config.asInput+        . Session.State.eval+        . Input.withEff+            ( maybe def Session.idleTimeout+                . Session.State.Result.toMaybe+                <$> Session.State.currentSession+            )+        . IdleTimer.quitOnTimeout         $ do             installTerminationHandler             Log.info $ "Starting tricorder " <> Version.gitHash-            Conc.fork_ Core.main+            Conc.fork_ $ Sub.listen_ Watch.publishChange             Conc.fork_ Server.main-            Conc.awaitAll++            vacuous $ Session.State.withSession \sessionResult -> do+                case sessionResult of+                    Session.State.Result.Invalid invalid -> do+                        let msg =+                                "Unable to start builder due to invalid session: "+                                    <> Session.State.Result.Invalid.reason invalid+                        Log.err msg+                        Pub.publish $ BuildPhase.Failed msg+                    Session.State.Result.Ok session -> do+                        void $ Conc.fork $ Hpack.main session+                        Conc.fork_ $ HookDispatch.main session++                        root <- Reader.ask+                        Conc.fork_ $ Watch.files root session+                        vacuous $ Core.main session
src/Tricorder/Daemon/TestRunner.hs view
@@ -53,13 +53,7 @@ import Data.List qualified as List import Data.Text qualified as T -import Tricorder.Daemon.GhciSession.GhciParser (GhciLoading (..), parseProgressLine)-import Tricorder.Daemon.GhciSession.GhciProcess-    ( GhciProcessError (..)-    , UnexpectedExit (..)-    , execGhci-    , withGhciProcess-    )+import Tricorder.Daemon.Ghci.Loading (Loading (..)) import Tricorder.Runtime (ProjectRoot (..)) import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..)) import Tricorder.Session.Stage (Stage (..))@@ -70,6 +64,9 @@ import Tricorder.TestOutput (parseHspecDuration, parseHspecOutput)  import Tricorder.Build.Test qualified as Test+import Tricorder.Daemon.Ghci.Loading qualified as Ghci (Loading)+import Tricorder.Daemon.Ghci.Process qualified as Ghci.Process+import Tricorder.Daemon.Ghci.Result qualified as Ghci.Result   data TestRunner :: Effect where@@ -117,7 +114,7 @@        , Reader ProjectRoot :> es        , Timeout :> es        )-    => (GhciLoading -> Eff es ())+    => (Ghci.Loading -> Eff es ())     -> TestTimeout     -> RenderedCommand 'Test     -> Eff es Test.Suite@@ -137,7 +134,7 @@             unlessM (File.hIsEOF h) do                 line <- File.hGetLine h                 atomically $ modifyTVar' outputVar (|> line)-                traverse_ onProgress (parseProgressLine line)+                traverse_ onProgress (Ghci.Result.parseProgressLine line)                 drain h          runToCompletion p = do@@ -192,14 +189,14 @@        , Reader ProjectRoot :> es        , Timeout :> es        )-    => (GhciLoading -> Eff es ())+    => (Ghci.Loading -> Eff es ())     -> TestTimeout     -> RenderedCommand 'Test     -> Eff es Test.Suite runTestSuiteWithGHCi onProgress testTimeout cmd = do     ProjectRoot projectRoot <- ask     result <- trySync-        $ withGhciProcess+        $ Ghci.Process.with             def             cmd             projectRoot@@ -207,22 +204,22 @@             noReady             \ghci _ ->                 case testTimeout of-                    TestTimeout secs | secs <= 0 -> Right <$> execGhci ghci ":main" noProgress+                    TestTimeout secs | secs <= 0 -> Right <$> Ghci.Process.exec ghci ":main" noProgress                     TestTimeout secs ->                         let duration = fromIntegral secs :: Second                         in  maybeToRight secs-                                <$> timeout duration (execGhci ghci ":main" noProgress)+                                <$> timeout duration (Ghci.Process.exec ghci ":main" noProgress)     case result of         Left ex -> case fromException ex of-            Just (e :: GhciProcessError) -> case e of-                UnexpectedExit (MkUnexpectedExit marker msg) ->+            Just (e :: Ghci.Process.Error) -> case e of+                Ghci.Process.UnexpectedExit (Ghci.Process.MkUnexpectedExit marker msg) ->                     pure                         $ Test.SuiteErrored                         $ Test.SuiteError                             { message = "Test suite failed while waiting for marker (" <> marker <> "):\n" <> msg                             }-                StartupTimeout -> pure $ Test.SuiteErrored $ Test.SuiteError "Test suite timed out before it could finish"-                StartupFailed msg ->+                Ghci.Process.StartupTimeout -> pure $ Test.SuiteErrored $ Test.SuiteError "Test suite timed out before it could finish"+                Ghci.Process.StartupFailed msg ->                     pure                         $ Test.SuiteErrored                         $ Test.SuiteError@@ -279,7 +276,7 @@             Right r : rest -> put rest >> pure r  -loadingToProgress :: GhciLoading -> Test.Suite+loadingToProgress :: Ghci.Loading -> Test.Suite loadingToProgress loading =     Test.SuiteRunning         $ Just
src/Tricorder/Daemon/Watch.hs view
@@ -17,13 +17,13 @@     , dirWhere     , excluding     )-import Atelier.Effects.Publishing.Pub (Pub)+import Atelier.Effects.PubSub.Pub (Pub) import System.FilePath (takeExtension, takeFileName) import Text.Regex.TDFA (ExecOption (..), blankCompOpt, blankExecOpt, match) import Text.Regex.TDFA.TDFA (patternToRegex)  import Atelier.Effects.FileWatcher qualified as FileWatcher-import Atelier.Effects.Publishing.Pub qualified as Pub+import Atelier.Effects.PubSub.Pub qualified as Pub  import Tricorder.Build.Changes     ( CabalChangeDetected (..)
src/Tricorder/Logging.hs view
@@ -1,19 +1,26 @@ module Tricorder.Logging (runLogging) where -import Atelier.Config (LoadedConfig, extractNestedConfig) import Atelier.Effects.Env (Env) import Atelier.Effects.File (BufferMode (..), File)-import Atelier.Effects.Input (Input, input)-import Atelier.Effects.Log (Log, Severity (..), runLogToHandle)+import Atelier.Effects.Input (Input)+import Atelier.Effects.Log+    ( Log+    , Severity (..)+    , runLogToHandle+    ) import Effectful (IOE) import Effectful.Reader.Static (Reader, asks)  import Atelier.Effects.File qualified as File+import Atelier.Effects.Input qualified as Input import Atelier.Effects.Log qualified as Log +import Tricorder.Config.LoadedConfig (LoadedConfig) import Tricorder.Runtime (LogPath (..)) +import Tricorder.Config.LoadedConfig qualified as LoadedConfig + runLogging     :: ( Env :> es        , File :> es@@ -26,16 +33,25 @@     path <- asks @LogPath (.getLogPath)     File.withFile path AppendMode \h -> do         File.hSetBuffering h LineBuffering-        sev <- getMinimumSeverity-        runLogToHandle h sev act+        (maybeErr, sev) <- getMinimumSeverity+        runLogToHandle h sev do+            whenJust maybeErr \err -> Log.err err+            act  -getMinimumSeverity :: (Env :> es, Input LoadedConfig :> es) => Eff es Severity+getMinimumSeverity+    :: ( Env :> es+       , Input LoadedConfig :> es+       )+    => Eff es (Maybe Text, Severity) getMinimumSeverity = do     mSev <- Log.minimumSeverityFromEnv     case mSev of-        Just sev -> pure sev-        Nothing -> do-            loadedConfig <- input-            let config = extractNestedConfig @"session.logging" @Log.Config loadedConfig-            pure config.minimumSeverity+        Just sev -> pure (Nothing, sev)+        Nothing -> LoadedConfig.asNestedInput @"session.logging" @Log.Config do+            logConfigRes <- Input.input+            case logConfigRes of+                Left err -> do+                    pure (Just $ "failed to parse log config: " <> err, Log.INFO)+                Right cfg ->+                    pure (Nothing, cfg.minimumSeverity)
src/Tricorder/Session.hs view
@@ -1,23 +1,21 @@ module Tricorder.Session     ( Session (..)-    , loadSession-    , inputSession-    , show+    , load+    , display     ) where -import Atelier.Config (LoadedConfig, extractConfig) import Atelier.Effects.FileSystem (FileSystem)-import Atelier.Effects.Input (Input, input, runInputEff)+import Atelier.Effects.Input (Input) import Atelier.Effects.Log (Log) import Data.Default (Default (..))-import Effectful.Reader.Static (Reader, ask)+import Effectful.Reader.Static (Reader) import Text.Regex.TDFA.Pattern (showPattern)-import Prelude hiding (show) +import Atelier.Effects.Input qualified as Input import Atelier.Effects.Log qualified as Log import Data.Text qualified as T-import Prelude qualified as P+import Effectful.Reader.Static qualified as Reader  import Tricorder.Build.ByteSize (ByteSize) import Tricorder.Runtime (ProjectRoot (..))@@ -40,7 +38,7 @@ import Tricorder.Session.Target (definesCustomPrelude) import Tricorder.Session.TestTarget (getTestTarget) import Tricorder.Session.TestTimeout (TestTimeout (..))-import Tricorder.Session.Util (indent, showList)+import Tricorder.Session.Util (displayList, indent) import Tricorder.Session.WatchDirs (WatchDirs (..)) import Tricorder.Session.WatchExclusionPatterns     ( WatchExclusionPatterns (..)@@ -48,6 +46,8 @@     )  import Tricorder.Build.ByteSize qualified as ByteSize+import Tricorder.Config qualified as Tricorder+import Tricorder.Session.Hooks qualified as Hooks import Tricorder.Session.Stage qualified as Stage import Tricorder.Session.Stage.Build.Session qualified as BuildSession import Tricorder.Session.Stage.Eval.Command qualified as EvalCommand@@ -89,24 +89,30 @@             }  -loadSession+load     :: ( FileSystem :> es-       , Input LoadedConfig :> es+       , Input (Either Text Tricorder.Config) :> es        , Input [CabalFile] :> es        , Log :> es        , Reader ProjectRoot :> es        )-    => Eff es Session-loadSession = do-    projectRoot <- ask @ProjectRoot-    loadedCfg <- input-    Log.debug "Got config file"-    projectFiles <- input-    Log.debug "Got project files"+    => Eff es (Either Text Session)+load = do+    cfg <- Input.input -    let cfgFile = extractConfig @"session" @Config loadedCfg-        hooks = fromMaybe def cfgFile.hooks+    case cfg of+        Left err -> do+            Log.err $ "Failed to parse config: " <> err+            pure $ Left $ "Failed to parse Tricorder config: " <> err+        Right (Tricorder.Config cfgFile) -> Right <$> mkSession (fromMaybe def cfgFile) ++mkSession+    :: (FileSystem :> es, Input [CabalFile] :> es, Log :> es, Reader ProjectRoot :> es)+    => Config -> Eff es Session+mkSession cfgFile = do+    projectRoot <- Reader.ask @ProjectRoot+    projectFiles <- Input.input     warnDeprecatedConfig cfgFile     Log.debug "Warned about deprecated config stuff" @@ -171,22 +177,11 @@             , replBuildDir = ReplBuildDir cfgFile.replBuildDir             , testTimeout = TestTimeout cfgFile.testTimeout             , generateWithHpack = GenerateWithHpack cfgFile.generateWithHpack-            , hooks+            , hooks = fromMaybe def cfgFile.hooks             , idleTimeout = IdleTimeout $ fromIntegral cfgFile.idleTimeoutSeconds             }  -inputSession-    :: ( FileSystem :> es-       , Input LoadedConfig :> es-       , Input [CabalFile] :> es-       , Log :> es-       , Reader ProjectRoot :> es-       )-    => Eff (Input Session : es) a -> Eff es a-inputSession = runInputEff loadSession-- -- | Warn once per session load for each deprecated top-level config key -- still in use, regardless of whether its replacement also takes -- precedence.@@ -236,27 +231,29 @@         _ -> pure ()  -show :: Session -> Text-show session =+display :: Session -> Text+display session =     T.intercalate         "\n"         [ "Loaded session"         , "Build configuration:"-        , indent $ BuildSession.show session.buildSession+        , indent $ BuildSession.display session.buildSession         , "Test configuration:"-        , indent $ TestSession.show session.testSession+        , indent $ TestSession.display session.testSession         , "Eval comments configuration:"-        , indent $ EvalSession.show session.evalSession+        , indent $ EvalSession.display session.evalSession         , "Watch dirs:"-        , indent $ showList toText session.watchDirs.getWatchDirs+        , indent $ displayList toText session.watchDirs.getWatchDirs         , "Watch exclusion patterns:"         , indent-            $ showList+            $ displayList                 (toText . showPattern . fst)                 session.watchExclusionPatterns.getWatchExclusionPatterns         , "Repl build dir: " <> toText session.replBuildDir.getReplBuildDir         , -- TODO: Remove " seconds" when TestTimeout is converted to a proper time unit.-          "Test timeout: " <> P.show session.testTimeout.getTestTimeout <> " seconds"-        , "Test memory limit: " <> P.show session.testMemoryLimit-        , "Generate with hpack: " <> P.show session.generateWithHpack.getGenerateWithHpack+          "Test timeout: " <> show session.testTimeout.getTestTimeout <> " seconds"+        , "Test memory limit: " <> show session.testMemoryLimit+        , "Generate with hpack: " <> show session.generateWithHpack.getGenerateWithHpack+        , "Hooks:"+        , indent $ Hooks.display session.hooks         ]
src/Tricorder/Session/CabalFile.hs view
@@ -11,24 +11,26 @@ import Atelier.Effects.Env (Env) import Atelier.Effects.FileSystem (FileSystem, doesFileExist, listDirectory, readFileBs) import Atelier.Effects.FileSystem.Glob (Glob, globDir1)-import Atelier.Effects.Input (Input, input, runInputEff)+import Atelier.Effects.Input (Input) import Atelier.Effects.Log (Log) import Data.Traversable (for) import Distribution.Fields (Field (..), FieldLine (..), Name (..), readFields) import Distribution.PackageDescription.Parsec (parseGenericPackageDescriptionMaybe) import Distribution.Types.GenericPackageDescription (GenericPackageDescription) import Effectful.Exception (catch, throwIO)-import Effectful.Reader.Static (Reader, ask)+import Effectful.Reader.Static (Reader) import System.FilePath (normalise, takeExtension, (</>)) import System.FilePath.Glob (compile) import System.IO.Error (userError)  import Atelier.Effects.Env qualified as Env import Atelier.Effects.FileSystem qualified as FileSystem+import Atelier.Effects.Input qualified as Input import Atelier.Effects.Log qualified as Log import Data.ByteString.Char8 qualified as BC import Data.List qualified as List import Data.Text qualified as T+import Effectful.Reader.Static qualified as Reader  import Tricorder.Runtime (ProjectRoot (..)) import Tricorder.Session.StackProject (StackProject (..))@@ -50,7 +52,7 @@        , Reader ProjectRoot :> es        )     => Eff (Input [CabalFile] : es) a -> Eff es a-inputCabalFiles = runInputEff $ Log.withNamespace "CabalFile" do+inputCabalFiles = Input.withEff $ Log.withNamespace "CabalFile" do     packageRes <- discoverPackages     case packageRes of         Left err -> throwIO $ userError $ toString err@@ -108,7 +110,7 @@        )     => Eff es (Either Text [FilePath]) discoverPackages = do-    ProjectRoot projectRoot <- ask+    ProjectRoot projectRoot <- Reader.ask     hasStackYaml <- FileSystem.doesFileExist $ projectRoot </> "stack.yaml"     if hasStackYaml         then discoverStackPackages@@ -125,7 +127,7 @@        )     => Eff es (Either Text [FilePath]) discoverCabalPackages = do-    ProjectRoot projectRoot <- ask+    ProjectRoot projectRoot <- Reader.ask     homeCabalFiles <- maybe [] (one . (</> ".cabal/config")) <$> Env.lookupEnv "HOME"     let projectFilePaths = projectCabalFiles projectRoot <> homeCabalFiles     projectFiles <- filterM doesFileExist projectFilePaths@@ -163,8 +165,8 @@     :: (Input StackProject :> es, Reader ProjectRoot :> es)     => Eff es (Either Text [FilePath]) discoverStackPackages = do-    ProjectRoot projectRoot <- ask-    project <- input+    ProjectRoot projectRoot <- Reader.ask+    project <- Input.input     pure $ Right $ normalise . (projectRoot </>) <$> project.packages  
src/Tricorder/Session/CommandConfig.hs view
@@ -1,6 +1,6 @@ module Tricorder.Session.CommandConfig (CommandConfig (..)) where -import Atelier.Types.QuietSnake (QuietSnake (..))+import Atelier.Types.JSON.QuietSnake (QuietSnake (..)) import Data.Aeson (FromJSON, ToJSON) import Data.Default (Default (..)) 
src/Tricorder/Session/CommandTemplate.hs view
@@ -5,21 +5,19 @@     , hasPlaceholder     , targetsPlaceholder     , targetPlaceholder-    , show+    , display     ) where  import Data.Default (Default (..))-import Prelude hiding (show)  import Data.List qualified as List import Data.Text qualified as T-import Prelude qualified as P  import Tricorder.Session.Repl (Repl (..)) import Tricorder.Session.Stage (Stage (..)) import Tricorder.Session.Target (Target (..))-import Tricorder.Session.Util (indent, showList)+import Tricorder.Session.Util (displayList, indent)  import Tricorder.Session.Target qualified as Target @@ -111,13 +109,13 @@ hasPlaceholder placeholderName template = ("{" <> placeholderName <> "}") `T.isInfixOf` template  -show :: CommandTemplate stage -> Text-show tmpl =+display :: CommandTemplate stage -> Text+display tmpl =     T.intercalate         "\n"         [ "Template: " <> tmpl.template-        , "Repl: " <> P.show tmpl.repl-        , "Template placeholder: " <> P.show tmpl.placeholder+        , "Repl: " <> show tmpl.repl+        , "Template placeholder: " <> show tmpl.placeholder         , "Arguments:"-        , indent $ showList id tmpl.arguments+        , indent $ displayList id tmpl.arguments         ]
src/Tricorder/Session/Config.hs view
@@ -1,8 +1,8 @@ module Tricorder.Session.Config (Config (..)) where -import Atelier.Types.QuietSnake (QuietSnake (..))+import Atelier.Types.JSON.QuietSnake (QuietSnake (..)) import Atelier.Types.WithDefaults (WithDefaults (..))-import Data.Aeson (FromJSON (..))+import Data.Aeson (FromJSON (..), ToJSON) import Data.Default (Default (..))  import Tricorder.Session.CommandConfig (CommandConfig)@@ -35,6 +35,7 @@     -- TODO: Remove at or after version 0.8.0.0.     }     deriving stock (Eq, Generic, Show)+    deriving (ToJSON) via QuietSnake Config     deriving (FromJSON) via WithDefaults (QuietSnake Config)  
+ src/Tricorder/Session/HaskellHooks.hs view
@@ -0,0 +1,43 @@+module Tricorder.Session.HaskellHooks+    ( HaskellHooks (..)+    , display+    )+where++import Atelier.Types.JSON.QuietSnake (QuietSnake (..))+import Data.Aeson (FromJSON, ToJSON)+import Data.Default (Default (..))++import Data.Text qualified as T++import Tricorder.Session.Hook (Hook)++import Tricorder.Session.Hook qualified as Hook+++data HaskellHooks = HaskellHooks+    { fileAdded :: Maybe Hook+    , fileModified :: Maybe Hook+    , fileRemoved :: Maybe Hook+    }+    deriving stock (Eq, Generic, Show)+    deriving (FromJSON, ToJSON) via QuietSnake HaskellHooks+++instance Default HaskellHooks where+    def =+        HaskellHooks+            { fileAdded = Nothing+            , fileModified = Nothing+            , fileRemoved = Nothing+            }+++display :: HaskellHooks -> Text+display hooks =+    T.intercalate+        "\n"+        [ "file_added: " <> maybe "<unset>" Hook.getHook hooks.fileAdded+        , "file_modified: " <> maybe "<unset>" Hook.getHook hooks.fileModified+        , "file_removed: " <> maybe "<unset>" Hook.getHook hooks.fileRemoved+        ]
+ src/Tricorder/Session/Hook.hs view
@@ -0,0 +1,31 @@+module Tricorder.Session.Hook+    ( Hook (..)+    , run+    )+where++import Atelier.Effects.Process (Process)+import Data.Aeson (FromJSON, ToJSON)+import Effectful.Exception (trySync)+import Effectful.Reader.Static (Reader)++import Atelier.Effects.Process qualified as Process+import Effectful.Reader.Static qualified as Reader++import Tricorder.Runtime (ProjectRoot (..))+++newtype Hook = Hook {getHook :: Text}+    deriving stock (Eq, Generic, Show)+    deriving (FromJSON, ToJSON) via Text+++run :: (Process :> es, Reader ProjectRoot :> es) => Hook -> Eff es ()+run (Hook hook) = do+    ProjectRoot root <- Reader.ask+    void+        $ trySync+        $ Process.runProcess+        $ Process.setWorkingDir root+        $ Process.shell+        $ toString hook
src/Tricorder/Session/Hooks.hs view
@@ -1,29 +1,36 @@+{-# LANGUAGE QuantifiedConstraints #-}+{-# LANGUAGE UndecidableInstances #-}+ module Tricorder.Session.Hooks     ( Hooks (..)-    , Hook (..)-    , runHook+    , HookPair (..)+    , display     ) where -import Atelier.Effects.Process (Process)-import Atelier.Types.QuietSnake (QuietSnake (..))+import Atelier.Types.JSON.QuietSnake (QuietSnake (..))+import Atelier.Types.WithDefaults (WithDefaults (..)) import Data.Aeson (FromJSON, ToJSON) import Data.Default (Default (..))-import Effectful.Exception (trySync)-import Effectful.Reader.Static (Reader) -import Atelier.Effects.Process qualified as Process-import Effectful.Reader.Static qualified as Reader+import Data.Text qualified as T -import Tricorder.Runtime (ProjectRoot (..))+import Tricorder.Session.HaskellHooks (HaskellHooks)+import Tricorder.Session.Hook (Hook)+import Tricorder.Session.Util (indent) +import Tricorder.Session.HaskellHooks qualified as HaskellHooks+import Tricorder.Session.Hook qualified as Hook + data Hooks = Hooks-    { start :: Maybe Hook-    , reload :: Maybe Hook+    { start :: Maybe HookPair+    , reload :: Maybe HookPair+    , haskell :: Maybe HaskellHooks     }     deriving stock (Eq, Generic, Show)-    deriving (FromJSON, ToJSON) via QuietSnake Hooks+    deriving (ToJSON) via QuietSnake Hooks+    deriving (FromJSON) via WithDefaults (QuietSnake Hooks)   instance Default Hooks where@@ -31,31 +38,43 @@         Hooks             { start = def             , reload = def+            , haskell = def             }  -data Hook = Hook-    { before :: Maybe Text-    , after :: Maybe Text+display :: Hooks -> Text+display hooks =+    T.intercalate+        "\n"+        [ "start:" <> displayMaybeHook displayHookPair hooks.start+        , "reload:" <> displayMaybeHook displayHookPair hooks.reload+        , "haskell:" <> displayMaybeHook HaskellHooks.display hooks.haskell+        ]+  where+    displayMaybeHook f = maybe " <unset>" (("\n" <>) . indent . f)+++data HookPair = HookPair+    { before :: Maybe Hook+    , after :: Maybe Hook     }     deriving stock (Eq, Generic, Show)-    deriving (FromJSON, ToJSON) via QuietSnake Hook+    deriving (ToJSON) via QuietSnake HookPair+    deriving (FromJSON) via WithDefaults (QuietSnake HookPair)  -instance Default Hook where+instance Default HookPair where     def =-        Hook+        HookPair             { before = Nothing             , after = Nothing             }  -runHook :: (Process :> es, Reader ProjectRoot :> es) => Text -> Eff es ()-runHook hook = do-    ProjectRoot root <- Reader.ask-    void-        $ trySync-        $ Process.runProcess-        $ Process.setWorkingDir root-        $ Process.shell-        $ toString hook+displayHookPair :: HookPair -> Text+displayHookPair pair =+    T.intercalate+        "\n"+        [ "before: " <> maybe "<unset>" Hook.getHook pair.before+        , "after: " <> maybe "<unset>" Hook.getHook pair.after+        ]
src/Tricorder/Session/StackProject.hs view
@@ -5,7 +5,7 @@ where  import Atelier.Effects.FileSystem (FileSystem)-import Atelier.Effects.Input (Input, runInputEff)+import Atelier.Effects.Input (Input) import Data.Aeson (FromJSON, ToJSON) import Data.Time (UTCTime) import Data.Time.Clock.POSIX (posixSecondsToUTCTime)@@ -24,6 +24,7 @@ import System.IO.Error (userError)  import Atelier.Effects.FileSystem qualified as FileSystem+import Atelier.Effects.Input qualified as Input import Data.Yaml qualified as Yaml  import Tricorder.Runtime (ProjectRoot (..))@@ -45,7 +46,7 @@ inputWithCache act = do     cache :: TVar StackProject <- newTVarIO $ StackProject []     lastModified :: TVar UTCTime <- newTVarIO $ posixSecondsToUTCTime 0-    flip runInputEff act do+    flip Input.withEff act do         ProjectRoot projectRoot <- ask         let stackYamlPath = projectRoot </> "stack.yaml"         exists <- FileSystem.doesFileExist stackYamlPath
src/Tricorder/Session/Stage/Build/Session.hs view
@@ -1,14 +1,13 @@ module Tricorder.Session.Stage.Build.Session     ( BuildSession (..)     , resolve-    , show+    , display     ) where  import Atelier.Effects.FileSystem (FileSystem) import Data.Default (Default (..)) import Effectful.Reader.Static (Reader)-import Prelude hiding (show)  import Data.Text qualified as T import Effectful.Reader.Static qualified as Reader@@ -20,7 +19,7 @@ import Tricorder.Session.Config (Config (..)) import Tricorder.Session.Repl (Repl) import Tricorder.Session.Target (Target)-import Tricorder.Session.Util (indent, showList)+import Tricorder.Session.Util (displayList, indent)  import Tricorder.Session.CommandTemplate qualified as CommandTemplate import Tricorder.Session.Stage qualified as Stage@@ -55,12 +54,12 @@     effectiveTargets = Target.resolve projectFiles rawBuildTargets  -show :: BuildSession -> Text-show cfg =+display :: BuildSession -> Text+display cfg =     T.intercalate         "\n"         [ "Command template:"-        , indent $ CommandTemplate.show cfg.commandTemplate+        , indent $ CommandTemplate.display cfg.commandTemplate         , "Targets:"-        , indent $ showList Target.render cfg.targets+        , indent $ displayList Target.render cfg.targets         ]
src/Tricorder/Session/Stage/Eval/Session.hs view
@@ -1,11 +1,9 @@ module Tricorder.Session.Stage.Eval.Session     ( EvalSession-    , show+    , display     ) where -import Prelude hiding (show)- import Data.Text qualified as T  import Tricorder.Session.CommandTemplate (CommandTemplate)@@ -18,10 +16,10 @@ type EvalSession = CommandTemplate 'Stage.Eval  -show :: EvalSession -> Text-show cfg =+display :: EvalSession -> Text+display cfg =     T.intercalate         "\n"         [ "Command template:"-        , indent $ CommandTemplate.show cfg+        , indent $ CommandTemplate.display cfg         ]
src/Tricorder/Session/Stage/Test/Config.hs view
@@ -5,7 +5,7 @@     ) where -import Atelier.Types.QuietSnake (QuietSnake (..))+import Atelier.Types.JSON.QuietSnake (QuietSnake (..)) import Data.Aeson (FromJSON (..), ToJSON, Value (..)) import Data.Aeson.Types (ToJSON (..)) import Data.Default (Default (..))
src/Tricorder/Session/Stage/Test/Session.hs view
@@ -3,15 +3,13 @@     , ResolvedTestOptions (..)     , resolve     , defaultTestTemplate-    , show+    , display     ) where  import Data.Default (Default (..))-import Prelude hiding (show)  import Data.Text qualified as T-import Prelude qualified as P  import Tricorder.Session.CommandConfig (CommandConfig (..)) import Tricorder.Session.CommandTemplate (CommandTemplate (..), targetPlaceholder)@@ -20,7 +18,7 @@ import Tricorder.Session.Stage.Test.Config (Options (..), OutputMode (..), TestConfig (..)) import Tricorder.Session.Target (Target) import Tricorder.Session.TestTarget (TestTarget (..))-import Tricorder.Session.Util (indent, showList)+import Tricorder.Session.Util (displayList, indent)  import Tricorder.Session.CommandTemplate qualified as CommandTemplate import Tricorder.Session.Stage qualified as Stage@@ -92,14 +90,14 @@     Unknown -> "cabal repl {target}"  -show :: TestSession -> Text-show cfg =+display :: TestSession -> Text+display cfg =     T.intercalate         "\n"         [ "Command template:"-        , indent $ CommandTemplate.show cfg.commandTemplate+        , indent $ CommandTemplate.display cfg.commandTemplate         , "Test targets:"-        , indent $ showList TestTarget.render cfg.targets+        , indent $ displayList TestTarget.render cfg.targets         , "Options:"         , indent $ showTestOptions cfg.options         ]@@ -107,5 +105,5 @@     showTestOptions opts =         T.intercalate             "\n"-            [ "Output mode: " <> P.show opts.outputMode+            [ "Output mode: " <> show opts.outputMode             ]
+ src/Tricorder/Session/State.hs view
@@ -0,0 +1,173 @@+module Tricorder.Session.State+    ( State+    , currentSession+    , withSession+    , asInput+    , eval+    )+where++import Atelier.Effects.Chan (Chan)+import Atelier.Effects.Clock (Clock)+import Atelier.Effects.Conc (Conc)+import Atelier.Effects.Debounce (Debounce)+import Atelier.Effects.FileSystem (FileSystem)+import Atelier.Effects.FileWatcher (FileWatcher)+import Atelier.Effects.Input (Input)+import Atelier.Effects.Log (Log)+import Atelier.Effects.PubSub.Pub (Pub)+import Atelier.Effects.PubSub.Sub (Sub)+import Effectful (Effect, Limit (..), Persistence (..), UnliftStrategy (..), inject)+import Effectful.Concurrent.STM (Concurrent, TVar)+import Effectful.Dispatch.Dynamic (interpretWith, localUnlift)+import Effectful.Reader.Static (Reader)+import Effectful.TH (makeEffect)+import System.FilePath ((</>))++import Atelier.Effects.Conc qualified as Conc+import Atelier.Effects.FileSystem qualified as FileSystem+import Atelier.Effects.FileWatcher qualified as FileWatcher+import Atelier.Effects.Input qualified as Input+import Atelier.Effects.Log qualified as Log+import Atelier.Effects.PubSub qualified as PubSub+import Atelier.Effects.PubSub.Pub qualified as Pub+import Atelier.Effects.PubSub.Sub qualified as Sub+import Data.List qualified as List+import Data.Text qualified as T+import Effectful.Concurrent.STM qualified as STM+import Effectful.Reader.Static qualified as Reader++import Tricorder.Runtime (ProjectRoot (..))+import Tricorder.Session.CabalFile (CabalFile)+import Tricorder.Session.State.Invalid (Invalid (..))+import Tricorder.Session.State.Result (Result)+import Tricorder.Session.Util (indent)+import Tricorder.Waiters (Waiters)++import Tricorder.Config qualified as Tricorder+import Tricorder.Config.LoadedConfig qualified as LoadedConfig+import Tricorder.Session qualified as Session+import Tricorder.Session.State.Result qualified as Result+import Tricorder.Waiters qualified as Waiters+++data State :: Effect where+    -- | Returns the current session record.+    CurrentSession :: State m Result+    -- | Cancels and reruns the passed action with a new session every time the+    -- session record changes.+    WithSession :: (Result -> m a) -> State m Void+++makeEffect ''State+++asInput :: (State :> es) => Eff (Input Result : es) a -> Eff es a+asInput = Input.withEff currentSession+++eval+    :: ( Chan :> es+       , Clock :> es+       , Conc :> es+       , Concurrent :> es+       , Debounce FilePath :> es+       , FileSystem :> es+       , FileWatcher :> es+       , Input (Either Text Tricorder.Config) :> es+       , Input [CabalFile] :> es+       , Log :> es+       , Reader ProjectRoot :> es+       , Waiters :> es+       )+    => Eff (State : es) a -> Eff es a+eval action = PubSub.run @Reloaded $ PubSub.run @Reload do+    session0 <- Result.fromEither <$> Session.load++    logSession session0+    var <- STM.newTVarIO session0+    Conc.fork_ watchConfigFile+    conditionallyWatchStackYaml+    Conc.fork_ $ Sub.listen_ @Reload $ const $ updateSession var+    interpretWith (inject action) \env -> \case+        CurrentSession -> STM.readTVarIO var+        WithSession act -> do+            localUnlift env (ConcUnlift Persistent Unlimited) \unlift ->+                Conc.restartableFork waitForReloadedSession do+                    unlift . act =<< STM.readTVarIO var+++waitForReloadedSession :: (Sub Reloaded :> es, Waiters :> es) => Eff es Reloaded+waitForReloadedSession = Waiters.wait $ Sub.listenOnce_ @Reloaded+++updateSession+    :: ( Concurrent :> es+       , FileSystem :> es+       , Input (Either Text Tricorder.Config) :> es+       , Input [CabalFile] :> es+       , Log :> es+       , Pub Reloaded :> es+       , Reader ProjectRoot :> es+       )+    => TVar Result -> Eff es ()+updateSession var = do+    oldSession <- STM.readTVarIO var+    newSession <- Result.fromEither <$> Session.load+    when (oldSession /= newSession) do+        STM.atomically $ STM.writeTVar var newSession+        logSession newSession+        Pub.publish Reloaded+++data Reload = Reload+++data Reloaded = Reloaded+++watchConfigFile+    :: ( Debounce FilePath :> es+       , FileWatcher :> es+       , Pub Reload :> es+       , Reader ProjectRoot :> es+       )+    => Eff es Void+watchConfigFile = do+    root <- Reader.ask+    FileWatcher.watchFilePathsDebounced+        [FileWatcher.dirWhere root.getProjectRoot (LoadedConfig.configFileName `List.isSuffixOf`)]+        \_ _ -> Pub.publish Reload+++conditionallyWatchStackYaml+    :: ( Conc :> es+       , Debounce FilePath :> es+       , FileSystem :> es+       , FileWatcher :> es+       , Pub Reload :> es+       , Reader ProjectRoot :> es+       )+    => Eff es ()+conditionallyWatchStackYaml = do+    root <- Reader.ask+    exists <- FileSystem.doesFileExist $ root.getProjectRoot </> "stack.yaml"+    when exists do+        Conc.fork_ $ FileWatcher.watchFilePathsDebounced+            [FileWatcher.dirWhere root.getProjectRoot ("stack.yaml" `List.isSuffixOf`)]+            \_ _ -> Pub.publish Reload+++logSession :: (Log :> es) => Result -> Eff es ()+logSession = \case+    Result.Invalid invalid ->+        Log.err+            $ T.intercalate+                "\n"+                [ "Invalid session:"+                , indent $ "reason: " <> invalid.reason+                , indent $ "fallback:"+                , indent $ indent $ Session.display invalid.fallback+                ]+    Result.Ok session ->+        Log.info . (<> "\n") $ Session.display session
+ src/Tricorder/Session/State/Invalid.hs view
@@ -0,0 +1,10 @@+module Tricorder.Session.State.Invalid (Invalid (..)) where++import Tricorder.Session (Session)+++data Invalid = Invalid+    { reason :: Text+    , fallback :: Session+    }+    deriving stock (Eq)
+ src/Tricorder/Session/State/Result.hs view
@@ -0,0 +1,43 @@+module Tricorder.Session.State.Result+    ( Result (..)+    , fromEither+    , toEither+    , toMaybe+    )+where++import Data.Default (def)++import Tricorder.Session (Session)+import Tricorder.Session.State.Invalid (Invalid)++import Tricorder.Session.State.Invalid qualified as Invalid+++data Result+    = Ok Session+    | Invalid Invalid+    deriving stock (Eq)+++fromEither :: Either Text Session -> Result+fromEither = \case+    Left reason ->+        Invalid+            Invalid.Invalid+                { reason+                , fallback = def+                }+    Right session -> Ok session+++toEither :: Result -> Either Invalid Session+toEither = \case+    Ok a -> Right a+    Invalid b -> Left b+++toMaybe :: Result -> Maybe Session+toMaybe = \case+    Ok x -> Just x+    Invalid _ -> Nothing
src/Tricorder/Session/TestTarget.hs view
@@ -29,19 +29,18 @@  -- | Parse raw target strings (e.g. the @test_targets@ config) and project them -- onto their test suites — non-test entries are dropped.-parse :: [Text] -> [TestTarget]-parse = project . map Target.parse+parse :: Text -> Maybe TestTarget+parse = project . Target.parse   -- | [tag:test_targets_invariant] Project a target list onto its test suites — -- the only way to build a 'TestTargets', so the @test:@-only invariant holds by -- construction.-project :: [Target] -> [TestTarget]-project = mapMaybe mk-  where-    mk tgt@(Qualified Test _) = Just $ TestTarget tgt-    mk tgt@(PackageQualified _ Test _) = Just $ TestTarget tgt-    mk _ = Nothing+project :: Target -> Maybe TestTarget+project = \case+    tgt@(Qualified Test _) -> Just $ TestTarget tgt+    tgt@(PackageQualified _ Test _) -> Just $ TestTarget tgt+    _ -> Nothing   -- | Resolve which test suites to run after a clean build. The explicit@@ -52,5 +51,5 @@ -- set, falls back to deriving test targets from the build 'targets'. resolve :: Config -> [Target] -> [TestTarget] resolve cfg targets = case cfg.test.commandConfig.targets <|> cfg.testTargets of-    Just explicit -> parse explicit-    Nothing -> project targets+    Just explicit -> mapMaybe parse explicit+    Nothing -> mapMaybe project targets
src/Tricorder/Session/Util.hs view
@@ -1,5 +1,5 @@ module Tricorder.Session.Util-    ( showList+    ( displayList     , indent     ) where@@ -7,8 +7,8 @@ import Data.Text qualified as T  -showList :: (a -> Text) -> [a] -> Text-showList f xs+displayList :: (a -> Text) -> [a] -> Text+displayList f xs     | null xs = "<empty list>"     | otherwise = T.intercalate "\n" $ (("- " <>) . f) <$> xs 
src/Tricorder/Session/WatchDirs.hs view
@@ -2,6 +2,7 @@     ( WatchDirs (..)     , resolve     , sourceDirsForTarget+    , allSourceDirs     ) where @@ -151,3 +152,18 @@     dirsForComponent components name =         let ucn = mkUnqualComponentName (toString name)         in  concatMap (componentDirs . condTreeData . snd) $ filter ((== ucn) . fst) components+++-- | The @hs-source-dirs@ of every component in the package, relative to the+-- package's own directory.+allSourceDirs :: GenericPackageDescription -> [FilePath]+allSourceDirs gpd =+    nub+        . sourceDirsForTarget gpd+        . Bare+        . toText+        . unPackageName+        . pkgName+        . package+        . packageDescription+        $ gpd
src/Tricorder/Socket/Client.hs view
@@ -20,6 +20,7 @@ import Effectful.Reader.Static (Reader, ask) import Effectful.State.Static.Shared (evalState, get, modify, put) import System.IO.Error (isEOFError)+import Tricorder.CLI.Command (Force) import Prelude hiding (force)  import Atelier.Effects.Delay qualified as Delay
src/Tricorder/Socket/Server.hs view
@@ -2,18 +2,22 @@  import Atelier.Effects.Conc (Conc) import Atelier.Effects.Exit (Exit, exitSuccess)+import Atelier.Effects.File (File)+import Atelier.Effects.FileSystem (FileSystem) import Atelier.Effects.Input (Input, input) import Atelier.Effects.Log (Log)-import Atelier.Effects.Publishing.Sub (Sub)+import Atelier.Effects.PubSub.Sub (Sub) import Data.Aeson (ToJSON, decode, encode)-import Effectful.Exception (IOException, finally)+import Effectful.Exception (IOException, finally, trySync) import Effectful.Reader.Static (Reader, ask) import Effectful.State.Static.Shared (State) import System.IO (Handle)  import Atelier.Effects.Conc qualified as Conc+import Atelier.Effects.File qualified as File+import Atelier.Effects.FileSystem qualified as FileSystem import Atelier.Effects.Log qualified as Log-import Atelier.Effects.Publishing.Sub qualified as Sub+import Atelier.Effects.PubSub.Sub qualified as Sub import Data.ByteString.Lazy qualified as BSL import Effectful.State.Static.Shared qualified as State @@ -27,15 +31,7 @@     , Query (..)     , StatusQuery (..)     )-import Tricorder.Socket.UnixSocket-    ( UnixSocket-    , acceptHandle-    , bindSocket-    , closeHandle-    , readLine-    , removeSocketFile-    , sendLine-    )+import Tricorder.Socket.UnixSocket (UnixSocket) import Tricorder.Version (VersionMismatch (..), checkVersion) import Tricorder.Waiters (Waiters) @@ -44,6 +40,7 @@ import Tricorder.Build.Test qualified as Test import Tricorder.Daemon.IdleTimer qualified as IdleTimer import Tricorder.Socket.Protocol qualified as Protocol+import Tricorder.Socket.UnixSocket qualified as UnixSocket import Tricorder.Waiters qualified as Waiters  @@ -54,6 +51,8 @@ main     :: ( Conc :> es        , Exit :> es+       , File :> es+       , FileSystem :> es        , IdleTimer :> es        , Input BuildId :> es        , Log :> es@@ -67,13 +66,14 @@     Conc.fork_ $ Sub.listen_ @BuildPhase State.put      SocketPath sockPath <- ask-    removeSocketFile sockPath+    void $ trySync $ FileSystem.removeFile sockPath     acceptTrigger   acceptTrigger     :: ( Conc :> es        , Exit :> es+       , File :> es        , IdleTimer :> es        , Input BuildId :> es        , Log :> es@@ -86,28 +86,28 @@     => Eff es Void acceptTrigger = do     SocketPath sockPath <- ask-    sock <- bindSocket sockPath+    sock <- UnixSocket.bind sockPath     forever do-        h <- acceptHandle sock+        h <- UnixSocket.accept sock         void $ Conc.forkTry @IOException do-            handleConnection h `finally` closeHandle h+            handleConnection h `finally` File.hClose h   handleConnection     :: ( Conc :> es        , Exit :> es+       , File :> es        , IdleTimer :> es        , Input BuildId :> es        , Log :> es        , State BuildPhase :> es        , Sub BuildPhase :> es-       , UnixSocket :> es        , Waiters :> es        )     => Handle     -> Eff es () handleConnection h = IdleTimer.withActivity do-    line <- readLine h+    line <- File.hGetLine h     case decode (BSL.fromStrict (encodeUtf8 line)) of         Nothing -> sendJson h (ErrorResponse "invalid request")         Just ClientMessage {clientVersion, payload} -> do@@ -122,11 +122,11 @@ dispatch     :: ( Conc :> es        , Exit :> es+       , File :> es        , Input BuildId :> es        , Log :> es        , State BuildPhase :> es        , Sub BuildPhase :> es-       , UnixSocket :> es        , Waiters :> es        )     => Query@@ -142,8 +142,8 @@  quit     :: ( Exit :> es+       , File :> es        , Log :> es-       , UnixSocket :> es        , Waiters :> es        )     => Handle -> Protocol.Waiters -> Eff es ()@@ -158,9 +158,9 @@   respondOnce-    :: ( Input BuildId :> es+    :: ( File :> es+       , Input BuildId :> es        , State BuildPhase :> es-       , UnixSocket :> es        )     => Handle -> Eff es () respondOnce h = do@@ -170,10 +170,10 @@  respondWhenDone     :: ( Conc :> es+       , File :> es        , Input BuildId :> es        , State BuildPhase :> es        , Sub BuildPhase :> es-       , UnixSocket :> es        )     => Handle -> Eff es () respondWhenDone h = awaitResult >>= mkBuildState >>= sendJson h@@ -194,10 +194,10 @@  -- | Stream a JSON object after each state change event. watchStream-    :: ( Input BuildId :> es+    :: ( File :> es+       , Input BuildId :> es        , State BuildPhase :> es        , Sub BuildPhase :> es-       , UnixSocket :> es        )     => Handle -> Eff es () watchStream h = do@@ -207,7 +207,7 @@         mkBuildState progress >>= sendJson h  -respondDiagnostic :: (State BuildPhase :> es, UnixSocket :> es) => Int -> Handle -> Eff es ()+respondDiagnostic :: (File :> es, State BuildPhase :> es) => Int -> Handle -> Eff es () respondDiagnostic idx h = do     progress <- State.get     case progress of@@ -228,8 +228,8 @@         _ -> sendJson h $ ErrorResponse "Build in progress"  -sendJson :: (ToJSON a, UnixSocket :> es) => Handle -> a -> Eff es ()-sendJson h val = sendLine h (decodeUtf8 (BSL.toStrict (encode val)))+sendJson :: (File :> es, ToJSON a) => Handle -> a -> Eff es ()+sendJson h val = File.hPutTextLn h (decodeUtf8 (BSL.toStrict (encode val))) >> File.hFlush h   mkBuildState :: (Input BuildId :> es) => BuildPhase -> Eff es BuildState
src/Tricorder/Socket/UnixSocket.hs view
@@ -1,24 +1,18 @@ module Tricorder.Socket.UnixSocket     ( -- * Effect       UnixSocket-    , bindSocket-    , acceptHandle+    , bind+    , accept     , withConnection-    , readLine-    , sendLine-    , closeHandle-    , removeSocketFile-    , socketFileExists        -- * Interpreters-    , runUnixSocketIO-    , runUnixSocketScripted+    , runIO+    , runScripted     , SocketScript (..)     ) where  import Atelier.Effects.File (BufferMode (..), File, Handle)-import Atelier.Exception (trySyncIO) import Effectful (Effect, IOE) import Effectful.Dispatch.Dynamic (interpretWith, localSeqUnlift, reinterpret) import Effectful.Exception (finally)@@ -29,38 +23,22 @@     , SockAddr (..)     , Socket     , SocketType (..)-    , accept-    , bind-    , defaultProtocol-    , listen-    , socket-    , socketToHandle     )-import System.Directory (doesPathExist, removeFile) import System.IO (hSetEncoding, utf8)  import Atelier.Effects.File qualified as File import Network.Socket qualified as Net+import Network.Socket qualified as Socket   data UnixSocket :: Effect where     -- | Create, bind, and listen on a Unix socket at the given path.-    BindSocket :: FilePath -> UnixSocket m Socket+    Bind :: FilePath -> UnixSocket m Socket     -- | Accept the next incoming connection and return a line-buffered 'Handle'.-    AcceptHandle :: Socket -> UnixSocket m Handle+    Accept :: Socket -> UnixSocket m Handle     -- | Connect to a Unix socket and run the callback with the resulting handle.     -- The handle is closed when the callback returns.     WithConnection :: FilePath -> (Handle -> m a) -> UnixSocket m a-    -- | Read a line from a connected handle.-    ReadLine :: Handle -> UnixSocket m Text-    -- | Write a line to a connected handle and flush.-    SendLine :: Handle -> Text -> UnixSocket m ()-    -- | Close a connected handle.-    CloseHandle :: Handle -> UnixSocket m ()-    -- | Remove the socket file, ignoring errors (e.g. file not found).-    RemoveSocketFile :: FilePath -> UnixSocket m ()-    -- | Check whether the socket file exists.-    SocketFileExists :: FilePath -> UnixSocket m Bool   makeEffect ''UnixSocket@@ -71,17 +49,17 @@ -- Socket creation and encoding setup require raw IO (no @File@ equivalent), -- but the handle-level reads, writes, buffering and closing go through the -- 'File' effect.-runUnixSocketIO :: (File :> es, IOE :> es) => Eff (UnixSocket : es) a -> Eff es a-runUnixSocketIO eff = interpretWith eff \env -> \case-    BindSocket path -> liftIO do-        sock <- socket AF_UNIX Stream defaultProtocol-        bind sock (SockAddrUnix path)-        listen sock 5+runIO :: (File :> es, IOE :> es) => Eff (UnixSocket : es) a -> Eff es a+runIO eff = interpretWith eff \env -> \case+    Bind path -> liftIO do+        sock <- Socket.socket AF_UNIX Stream Socket.defaultProtocol+        Socket.bind sock (SockAddrUnix path)+        Socket.listen sock 5         pure sock-    AcceptHandle sock -> do+    Accept sock -> do         h <- liftIO do-            (conn, _) <- accept sock-            h <- socketToHandle conn ReadWriteMode+            (conn, _) <- Socket.accept sock+            h <- Socket.socketToHandle conn ReadWriteMode             hSetEncoding h utf8             pure h         File.hSetBuffering h LineBuffering@@ -89,20 +67,13 @@     WithConnection sockPath callback ->         localSeqUnlift env \unlift -> do             h <- liftIO do-                sock <- socket AF_UNIX Stream defaultProtocol+                sock <- Socket.socket AF_UNIX Stream Socket.defaultProtocol                 Net.connect sock (SockAddrUnix sockPath)-                h <- socketToHandle sock ReadWriteMode+                h <- Socket.socketToHandle sock ReadWriteMode                 hSetEncoding h utf8                 pure h             File.hSetBuffering h LineBuffering             unlift (callback h) `finally` File.hClose h-    ReadLine h -> File.hGetLine h-    SendLine h line -> File.hPutTextLn h line >> File.hFlush h-    CloseHandle h -> File.hClose h-    RemoveSocketFile path ->-        liftIO $ void $ trySyncIO $ removeFile path-    SocketFileExists path ->-        liftIO $ doesPathExist path   -- | Script element for the test interpreter.@@ -125,12 +96,12 @@ -- line buffering on it. 'removeSocketFile' is always a no-op. -- 'socketFileExists' pops the next 'NextFileCheck' entry. -- 'withConnection' pops the next 'NextConnect' entry and passes it to the callback.-runUnixSocketScripted+runScripted     :: (File :> es, IOE :> es) => [SocketScript] -> Eff (UnixSocket : es) a -> Eff es a-runUnixSocketScripted script = reinterpret (evalState script) \env -> \case-    BindSocket _ ->-        liftIO $ Net.socket AF_UNIX Stream defaultProtocol-    AcceptHandle _ ->+runScripted script = reinterpret (evalState script) \env -> \case+    Bind _ ->+        liftIO $ Net.socket AF_UNIX Stream Socket.defaultProtocol+    Accept _ ->         get >>= \case             NextAccept h : rest -> do                 put rest@@ -143,14 +114,3 @@                 put rest                 localSeqUnlift env \unlift -> unlift (callback h)             _ -> error "UnixSocketScripted: expected NextConnect but queue was empty or mismatched"-    ReadLine _ ->-        get >>= \case-            NextReadLine line : rest -> put rest >> pure line-            _ -> error "UnixSocketScripted: expected NextReadLine but queue was empty or mismatched"-    SendLine _ _ -> pure ()-    CloseHandle _ -> pure ()-    RemoveSocketFile _ -> pure ()-    SocketFileExists _ ->-        get >>= \case-            NextFileCheck b : rest -> put rest >> pure b-            _ -> error "UnixSocketScripted: expected NextFileCheck but queue was empty or mismatched"
src/Tricorder/SourceLookup.hs view
@@ -1,29 +1,39 @@ module Tricorder.SourceLookup     ( ModuleSourceResult (..)+    , ReexportLimits (..)+    , defaultReexportLimits     , lookupModuleSource+    , ProjectSourceDirs (..)+    , projectSourceDirs     ) where -import Atelier.Effects.Cache (Cache, cacheInsert, cacheLookup)-import Atelier.Effects.FileSystem (FileSystem)+import Atelier.Effects.FileSystem (FileSystem, doesFileExist, readFileLbs) import Atelier.Effects.Input (Input, input) import Atelier.Effects.Log (Log) import Data.Aeson (FromJSON, ToJSON) import GHC.Generics (Generically (..))+import System.FilePath (normalise, takeDirectory, (</>)) import Tricorder.SourceLookup.SourceQuery (ModuleName (..), SourceQuery (..))  import Atelier.Effects.Log qualified as Log+import Data.Set qualified as Set+import Data.Text qualified as T +import Tricorder.Session.CabalFile (CabalFile (..)) import Tricorder.Session.Repl (Repl)+import Tricorder.Session.WatchDirs (allSourceDirs) import Tricorder.SourceLookup.GhcPkg (GhcPkg) import Tricorder.SourceLookup.Hackage (Hackage) import Tricorder.SourceLookup.PackageId (PackageId (..)) import Tricorder.SourceLookup.PackageStore (PackageStore)+import Tricorder.SourceLookup.Reexport (declaresMember, exportsByName, reexportCandidates) import Tricorder.SourceLookup.Slice (sliceSymbol) import Tricorder.SourceLookup.Tarball     ( TarballOutcome (..)     , obtainTarball     , readModuleMember+    , sourceExtensions     )  import Tricorder.SourceLookup.GhcPkg qualified as GhcPkg@@ -33,80 +43,254 @@ data ModuleSourceResult     = -- | Source was found; contains the module (or single-symbol) source text.       SourceFound SourceQuery Text-    | -- | The module is not provided by any installed package.+    | -- | The module is neither in the project nor in any installed package.       SourceNotFound SourceQuery     | -- | The package was resolved but no source tarball could be located or       -- fetched (no index, offline, yanked, or the archive could not be read).       SourceUnavailable SourceQuery PackageId+    | -- | The symbol is not defined in the queried module but re-exported by+      -- it; contains the module that defines it and the symbol's source there.+      SourceReexported SourceQuery ModuleName Text     | -- | The module source was found but the requested symbol was not in it.       FunctionNotFound SourceQuery     deriving stock (Eq, Generic, Show)     deriving (FromJSON, ToJSON) via Generically ModuleSourceResult  +-- | Bounds on the re-export search for a symbol the queried module does not+-- define itself (see 'followReexports').+data ReexportLimits = ReexportLimits+    { maxDepth :: Word+    -- ^ Most re-export hops to follow; @0@ disables following re-exports.+    , maxModules :: Word+    -- ^ Most modules to read during the search.+    }+    deriving stock (Eq, Show)+++-- | Enough for the deepest chains seen in practice (@Prelude#Maybe@ takes 3+-- hops through @ghc-internal@) while keeping a miss cheap.+defaultReexportLimits :: ReexportLimits+defaultReexportLimits = ReexportLimits {maxDepth = 5, maxModules = 24}+++-- | The directories holding the project's own modules: the @hs-source-dirs@ of+-- every component of every project package, resolved against the package dir.+newtype ProjectSourceDirs = ProjectSourceDirs [FilePath]+    deriving stock (Eq, Show)+++projectSourceDirs :: [CabalFile] -> ProjectSourceDirs+projectSourceDirs cabalFiles =+    ProjectSourceDirs+        $ ordNub+            [ normalise (takeDirectory cabalFile.projectFilePath </> dir)+            | cabalFile <- cabalFiles+            , dir <- orRoot (allSourceDirs cabalFile.projectPackageDescription)+            ]+  where+    -- A component without @hs-source-dirs@ keeps its sources in the package dir.+    orRoot [] = ["."]+    orRoot dirs = dirs+++-- | Where a module's source was found.+data Origin+    = -- | A source file under one of the project's @hs-source-dirs@.+      InProject+    | -- | The sdist tarball of an installed package.+      InPackage PackageId++ -- ── Lookup logic ───────────────────────────────────────────────────────────  -- | Resolve and return the source for a single module. ----- Resolves the module to its project-pinned 'PackageId' via @ghc-pkg@, then--- serves source from that package's sdist tarball in cabal's global cache,--- fetching it on demand if absent. A symbol query slices the relevant--- declaration (with its doc comment) from the module source. Both resolution--- steps are cached, so the fetch + read cost is paid at most once per--- (package, query).+-- A module defined in the project itself is read straight from its source+-- file. Otherwise the module is resolved to its project-pinned 'PackageId' via+-- @ghc-pkg@, and source is served from that package's sdist tarball in cabal's+-- global cache, fetching it on demand if absent. A symbol query slices the+-- relevant declaration (with its doc comment) from the module source. Both+-- package resolution steps are cached, so the fetch + read cost is paid at most+-- once per (package, query); project sources are always read fresh.+--+-- A symbol the module does not define itself is searched for in the modules it+-- may re-export it from (see 'followReexports'). lookupModuleSource-    :: ( Cache (PackageId, SourceQuery) ModuleSourceResult :> es-       , Cache ModuleName PackageId :> es-       , FileSystem :> es+    :: ( FileSystem :> es        , GhcPkg :> es        , Hackage :> es        , Input Repl :> es        , Log :> es        , PackageStore :> es        )-    => SourceQuery+    => ProjectSourceDirs+    -> ReexportLimits+    -> SourceQuery     -> Eff es ModuleSourceResult-lookupModuleSource query = do-    mPkg <- resolvePackage query.moduleName-    case mPkg of-        Nothing -> pure (SourceNotFound query)-        Just p -> do-            mCached <- cacheLookup @(PackageId, SourceQuery) @ModuleSourceResult (p, query)-            case mCached of-                Just result -> do-                    Log.debug $ "Source: " <> unModuleName query.moduleName <> " source hit (cached)"-                    pure result-                Nothing -> serveFromTarball query p+lookupModuleSource dirs limits query = do+    (mOrigin, result) <- lookupLocal dirs InProject query+    case (result, mOrigin, query.function) of+        (FunctionNotFound _, Just origin, Just symbol) ->+            fromMaybe result <$> followReexports dirs limits query symbol origin+        _ -> pure result  +-- | Look up a query in the module's own source, without following re-exports.+--+-- @parent@ is where the module was reached from. Reached from the project (or+-- queried directly), the project's own sources are searched first. Reached from+-- a package they are skipped, since a package cannot import project modules,+-- and when @ghc-pkg@ cannot place the module (e.g. a hidden @other-modules@+-- entry) the parent package is used instead. Also returns where the module was+-- found, if anywhere.+lookupLocal+    :: ( FileSystem :> es+       , GhcPkg :> es+       , Hackage :> es+       , Input Repl :> es+       , Log :> es+       , PackageStore :> es+       )+    => ProjectSourceDirs+    -> Origin+    -> SourceQuery+    -> Eff es (Maybe Origin, ModuleSourceResult)+lookupLocal dirs parent query = do+    mFile <- case parent of+        InProject -> findProjectModule dirs query.moduleName+        InPackage _ -> pure Nothing+    case mFile of+        Just path -> do+            Log.debug $ "Source: " <> unModuleName query.moduleName <> " → " <> toText path+            src <- decodeUtf8 . toStrict <$> readFileLbs path+            pure (Just InProject, fromModuleSource query src)+        Nothing -> do+            mPkg <- (<|> fallback) <$> resolvePackage query.moduleName+            case mPkg of+                Nothing -> pure (Nothing, SourceNotFound query)+                Just p -> do+                    (Just (InPackage p),) <$> serveFromTarball query p+  where+    fallback = case parent of+        InProject -> Nothing+        InPackage p -> Just p+++-- | Find the source file of a module defined in the project: the first+-- candidate path, in source dir then extension order, that exists.+findProjectModule+    :: (FileSystem :> es)+    => ProjectSourceDirs -> ModuleName -> Eff es (Maybe FilePath)+findProjectModule (ProjectSourceDirs dirs) modName =+    firstExisting [dir </> relPath <> toString ext | dir <- dirs, ext <- sourceExtensions]+  where+    relPath = toString $ T.replace "." "/" (unModuleName modName)+    firstExisting [] = pure Nothing+    firstExisting (path : rest) = ifM (doesFileExist path) (pure (Just path)) (firstExisting rest)+++-- | The result of a query against the source of the module it names.+fromModuleSource :: SourceQuery -> Text -> ModuleSourceResult+fromModuleSource query moduleSrc = case query.function of+    Nothing -> SourceFound query moduleSrc+    Just symbol -> case sliceSymbol symbol moduleSrc of+        Just slice -> SourceFound query slice+        Nothing -> FunctionNotFound query+++-- | Search the modules a symbol may be re-exported from (see+-- 'reexportCandidates'), starting at the queried module found at @origin@,+-- for the module that defines it.+--+-- The search is best-first: once a module's export list names the symbol (see+-- 'exportsByName'), its leads — and theirs, down the chain — are followed+-- depth-first, ahead of the remaining weaker candidates, which are searched+-- breadth-first. A hub like @GHC.Internal.Base@ re-exports through @module M@+-- entries that never name the symbol, so strength must be inherited for its+-- leads to be reached before the budget is spent. It is bounded in depth and in+-- the number of modules read, so a symbol that is genuinely absent costs only a+-- handful of lookups. It stops early at a module that declares the symbol as a+-- member of a local type or class (see 'declaresMember'): that module defines+-- it, so following other imports could only find an unrelated namesake.+followReexports+    :: ( FileSystem :> es+       , GhcPkg :> es+       , Hackage :> es+       , Input Repl :> es+       , Log :> es+       , PackageStore :> es+       )+    => ProjectSourceDirs+    -> ReexportLimits+    -> SourceQuery+    -> Text+    -> Origin+    -> Eff es (Maybe ModuleSourceResult)+followReexports dirs limits query symbol origin+    | limits.maxDepth <= 0 = pure Nothing+    | otherwise =+        candidatesIn origin query.moduleName >>= \case+            Nothing -> pure Nothing+            -- The queried module's candidates are one hop away.+            Just (_, roots) ->+                go+                    (Set.singleton query.moduleName)+                    limits.maxModules+                    [(m, mo, 1, False) | (m, mo) <- roots]+  where+    go _ _ [] = pure Nothing+    go seen budget ((m, parent, depth, onLead) : queue)+        | budget <= 0 = pure Nothing+        | m `Set.member` seen = go seen budget queue+        | otherwise = do+            Log.debug $ "Source: following re-export of " <> symbol <> " to " <> unModuleName m+            (mOrigin, result) <- lookupLocal dirs parent SourceQuery {moduleName = m, function = Just symbol}+            let seen' = Set.insert m seen+            case (result, mOrigin) of+                (SourceFound _ src, _) -> pure (Just (SourceReexported query m src))+                (FunctionNotFound _, Just mo)+                    | depth < limits.maxDepth ->+                        candidatesIn mo m >>= \case+                            Nothing -> pure Nothing+                            Just (namesIt, next) ->+                                -- A lead stays strong all the way down its chain.+                                let strong = onLead || namesIt+                                    leads = [(n, no, depth + 1, strong) | (n, no) <- next]+                                in  go seen' (budget - 1) (if strong then leads <> queue else queue <> leads)+                _ -> go seen' (budget - 1) queue++    -- The modules that may re-export the symbol to module @m@ (reached from+    -- @mo@), each paired with where @m@ was found as the parent to resolve it+    -- from, and whether @m@ names the symbol in its exports. 'Nothing' when @m@+    -- itself declares the symbol as a member of a local type or class.+    candidatesIn mo m =+        lookupLocal dirs mo SourceQuery {moduleName = m, function = Nothing} <&> \case+            (Just mo', SourceFound _ src)+                | declaresMember symbol src -> Nothing+                | otherwise -> Just (exportsByName symbol src, map (,mo') (reexportCandidates symbol src))+            _ -> Just (False, [])++ -- | Resolve a module to its package, consulting the module -> package cache first. resolvePackage-    :: ( Cache ModuleName PackageId :> es-       , GhcPkg :> es+    :: ( GhcPkg :> es        , Input Repl :> es        , Log :> es        )     => ModuleName     -> Eff es (Maybe PackageId) resolvePackage modName = do-    mCachedPkg <- cacheLookup @ModuleName @PackageId modName-    case mCachedPkg of-        Just p -> do-            Log.debug $ "Source: " <> unModuleName modName <> " → " <> unPackageId p <> " (cached)"-            pure (Just p)-        Nothing -> do-            repl <- input-            result <- GhcPkg.findModule repl modName-            Log.debug $ "Source: find-module " <> unModuleName modName <> " → " <> show result-            whenJust result (cacheInsert @ModuleName @PackageId modName)-            pure result+    repl <- input+    result <- GhcPkg.findModule repl modName+    Log.debug $ "Source: find-module " <> unModuleName modName <> " → " <> show result+    pure result   -- | Locate (or fetch) the package's tarball, read the module member, and slice--- the requested symbol if any. Caches and returns the result.+-- the requested symbol if any. serveFromTarball-    :: ( Cache (PackageId, SourceQuery) ModuleSourceResult :> es-       , FileSystem :> es+    :: ( FileSystem :> es        , Hackage :> es        , Log :> es        , PackageStore :> es@@ -116,25 +300,8 @@     -> Eff es ModuleSourceResult serveFromTarball query p =     obtainTarball p >>= \case-        -- A failed `cabal fetch` is transient (offline, stale index), so return-        -- unavailable WITHOUT caching: a later lookup retries once the network or-        -- index recovers, rather than serving the negative for the whole window.-        TarballFetchFailed -> pure (SourceUnavailable query p)-        -- The package is genuinely absent — a deterministic negative, safe to-        -- cache alongside the read/slice outcomes below.-        TarballAbsent -> cacheResult (SourceUnavailable query p)+        TarballFetchFailed -> pure $ SourceUnavailable query p+        TarballAbsent -> pure $ SourceUnavailable query p         TarballAt tarball -> do             mModuleSrc <- readModuleMember tarball query.moduleName-            cacheResult $ case mModuleSrc of-                Nothing -> SourceUnavailable query p-                Just moduleSrc -> case query.function of-                    Nothing -> SourceFound query moduleSrc-                    Just symbol -> case sliceSymbol symbol moduleSrc of-                        Just slice -> SourceFound query slice-                        Nothing -> FunctionNotFound query-  where-    -- Cache a deterministic outcome so the fetch + read/slice cost is paid at-    -- most once per (package, query) within the cache window.-    cacheResult result = do-        cacheInsert @(PackageId, SourceQuery) @ModuleSourceResult (p, query) result-        pure result+            pure $ maybe (SourceUnavailable query p) (fromModuleSource query) mModuleSrc
+ src/Tricorder/SourceLookup/Reexport.hs view
@@ -0,0 +1,343 @@+module Tricorder.SourceLookup.Reexport+    ( reexportCandidates+    , exportsByName+    , declaresMember+    )+where++import Data.Char (isAlphaNum, isSpace, isUpper)+import Data.List (partition)+import Tricorder.SourceLookup.SourceQuery (ModuleName (..))++import Data.Text qualified as T++import Tricorder.SourceLookup.Slice (sliceSymbol)+++reexportCandidates :: Text -> Text -> [ModuleName]+reexportCandidates symbol source =+    case moduleHeader stripped of+        Nothing -> []+        Just (self, exports) ->+            let imports = filter ((/= self) . (.modName)) (moduleImports stripped)+                routes = mapMaybe (exportRoute symbol definedLocally) exports+                matching = filter (\i -> any (routeMatches symbol i) routes) imports+                (strong, weak) = partition (namesSymbol symbol) matching+            in  ordNub (map (.modName) (strong <> weak))+  where+    stripped = stripComments source+    definedLocally n = isJust (sliceSymbol n source)+++exportsByName :: Text -> Text -> Bool+exportsByName symbol source =+    case moduleHeader (stripComments source) of+        Nothing -> False+        Just (_, exports) -> any names exports+  where+    names = \case+        NameItem _ n subs -> n == symbol || symbol `elem` subs+        ModuleItem _ -> False+++declaresMember :: Text -> Text -> Bool+declaresMember symbol source =+    case moduleHeader (stripComments source) of+        Nothing -> False+        Just (_, exports) -> any isLocalMember exports+  where+    isLocalMember = \case+        NameItem Nothing n subs+            | n /= symbol+            , symbol `elem` subs || ".." `elem` subs+            , Just decl <- sliceSymbol n source ->+                mentions decl+        _ -> False+    -- The symbol leads a line of the declaration (a method or field signature,+    -- a default method, a GADT constructor, a further @|@ alternative), heads a+    -- constructor alternative of the @data@ line, or is one of several names+    -- sharing a signature (@foo, bar :: …@). A mere use in a body does not count.+    mentions decl = any declares (T.lines decl)+    declares line =+        let sig = fst (T.breakOn "::" line)+            alternatives = drop 1 (T.split (`elem` ("=|" :: String)) (fst (T.breakOn "where" line)))+        in  leading line == symbol+                || ("::" `T.isInfixOf` line && symbol `elem` map leading (T.splitOn "," sig))+                || (isDataHead line && symbol `elem` map leading alternatives)+    isDataHead line = any (`T.isPrefixOf` line) ["data ", "newtype "]+    leading t =+        let s = T.dropWhile (\c -> isSpace c || c `elem` ("{,|" :: String)) t+        in  case T.stripPrefix "(" s of+                Just op -> T.strip (T.takeWhile (/= ')') op)+                Nothing -> T.takeWhile isIdentChar s+++-- | One entry of an export or import list.+data Item+    = -- | A name, its optional qualifier, and its sub-list (@..@ for a wildcard).+      NameItem (Maybe Text) Text [Text]+    | -- | A @module M@ entry.+      ModuleItem Text+++-- | How an export entry could carry the symbol out of the module.+data Route+    = -- | Via the named entity (under an optional qualifier).+      ViaName (Maybe Text) Text+    | -- | Via a @module M@ export.+      ViaModule Text+++exportRoute :: Text -> (Text -> Bool) -> Item -> Maybe Route+exportRoute symbol definedLocally = \case+    NameItem q n subs+        | n == symbol -> Just (ViaName q n)+        | symbol `elem` subs || ".." `elem` subs, not (definedLocally n) -> Just (ViaName q n)+        | otherwise -> Nothing+    ModuleItem m -> Just (ViaModule m)+++-- | Whether an import could bring @symbol@ into scope along a route.+routeMatches :: Text -> Import -> Route -> Bool+routeMatches symbol i = \case+    ViaName Nothing n -> not i.qualified && carries n+    ViaName (Just q) n -> q == scopeName i && carries n+    ViaModule m ->+        not i.qualified+            && (m == scopeName i || m == unModuleName i.modName)+            && (provides symbol i || hasWildcard i)+  where+    -- The import brings in the exported entity @n@ (or the symbol directly),+    -- and does not hide the symbol itself.+    carries n = (provides n i || provides symbol i) && not (i.hiding && namedIn i)+    namedIn imp = symbol `elem` concatMap itemNames (fromMaybe [] imp.items)+    hasWildcard imp = case imp.items of+        Just its | not imp.hiding -> any isWildcard its+        _ -> False+    isWildcard = \case+        NameItem _ _ subs -> ".." `elem` subs+        ModuleItem _ -> False+++data Import = Import+    { modName :: ModuleName+    , qualified :: Bool+    , alias :: Maybe Text+    , hiding :: Bool+    , items :: Maybe [Item]+    }+++scopeName :: Import -> Text+scopeName i = fromMaybe (unModuleName i.modName) i.alias+++provides :: Text -> Import -> Bool+provides name i = case i.items of+    Nothing -> True+    Just its+        | i.hiding -> name `notElem` concatMap itemNames its+        | otherwise -> name `elem` concatMap itemNames its+++namesSymbol :: Text -> Import -> Bool+namesSymbol symbol i = case i.items of+    Just its | not i.hiding -> symbol `elem` concatMap itemNames its+    _ -> False+++itemNames :: Item -> [Text]+itemNames = \case+    NameItem _ n subs -> n : filter (/= "..") subs+    ModuleItem _ -> []+++moduleImports :: Text -> [Import]+moduleImports = mapMaybe parseImport . groupImports . T.lines+  where+    groupImports [] = []+    groupImports (l : ls)+        | isImportLine l =+            let (cont, rest) = span isContinuation ls+            in  T.unwords (l : cont) : groupImports rest+        | otherwise = groupImports ls+    isImportLine l = case T.stripPrefix "import" l of+        Just rest -> maybe True (isSpace . fst) (T.uncons rest)+        Nothing -> False+    isContinuation l = case T.uncons l of+        Nothing -> True+        Just (c, _) -> isSpace c+++parseImport :: Text -> Maybe Import+parseImport decl = do+    afterImport <- T.stripPrefix "import" decl+    let (preQualified, afterPre) = skipPrefixes False (T.strip afterImport)+        (name, afterName) = T.span isModuleChar afterPre+    guard (not (T.null name))+    pure+        $ parseSuffix+            Import+                { modName = ModuleName name+                , qualified = preQualified+                , alias = Nothing+                , hiding = False+                , items = Nothing+                }+            (T.strip afterName)+  where+    -- @safe@, @qualified@, @{-# SOURCE #-}@ (already stripped), and a+    -- package-import string may precede the module name.+    skipPrefixes q t+        | Just rest <- T.stripPrefix "\"" t =+            skipPrefixes q (T.strip (T.drop 1 (T.dropWhile (/= '"') rest)))+        | otherwise = case T.breakOn " " t of+            ("safe", rest) -> skipPrefixes q (T.strip rest)+            ("qualified", rest) -> skipPrefixes True (T.strip rest)+            _ -> (q, t)+    parseSuffix i t+        | T.null t = i+        | "(" `T.isPrefixOf` t = case balanced t of+            Just (inner, rest) ->+                parseSuffix i {items = Just (mapMaybe parseItem (splitTopCommas inner))} (T.strip rest)+            Nothing -> i+        | otherwise =+            let (w, rest) = T.break (\c -> isSpace c || c == '(') t+            in  case w of+                    "qualified" -> parseSuffix i {qualified = True} (T.strip rest)+                    "hiding" -> parseSuffix i {hiding = True} (T.strip rest)+                    "as" ->+                        let (a, rest') = T.span isModuleChar (T.strip rest)+                        in  parseSuffix i {alias = Just a} (T.strip rest')+                    _ -> i+++moduleHeader :: Text -> Maybe (ModuleName, [Item])+moduleHeader src = do+    afterModule <- viaNonEmpty head (mapMaybe startsModule (lineStarts src))+    let (name, rest) = T.span isModuleChar (T.strip afterModule)+    guard (not (T.null name))+    (inner, _) <- balanced (T.strip rest)+    pure (ModuleName name, mapMaybe parseItem (splitTopCommas inner))+  where+    -- The text after a column-0 @module@ keyword.+    startsModule l = case T.stripPrefix "module" l of+        Just rest | maybe False (isSpace . fst) (T.uncons rest) -> Just rest+        _ -> Nothing+++lineStarts :: Text -> [Text]+lineStarts t = t : go t+  where+    go s = case T.breakOn "\n" s of+        (_, rest) | T.null rest -> []+        (_, rest) -> let s' = T.drop 1 rest in s' : go s'+++parseItem :: Text -> Maybe Item+parseItem raw+    | T.null t = Nothing+    | Just rest <- T.stripPrefix "module " t =+        Just (ModuleItem (T.takeWhile isModuleChar (T.strip rest)))+    | otherwise = do+        let t' = dropNamespace t+        (name, rest) <-+            if "(" `T.isPrefixOf` t'+                then first T.strip <$> balanced t'+                else Just (T.break (\c -> isSpace c || c == '(') t')+        guard (not (T.null name))+        let subs = case balanced (T.strip rest) of+                Just (inner, _) -> map (unparen . T.strip) (splitTopCommas inner)+                Nothing -> []+            (q, n) = splitQualified name+        pure (NameItem q n subs)+  where+    t = T.strip raw+    dropNamespace s = fromMaybe s (asum [T.strip <$> T.stripPrefix p s | p <- ["type ", "pattern "]])+    unparen s = maybe s (T.strip . fst) (balanced s)+++-- | Split a qualified name such as @T.pack@ or @Data.Text.Text@ into its+-- qualifier and its base name.+splitQualified :: Text -> (Maybe Text, Text)+splitQualified name =+    case T.splitOn "." name of+        segs@(_ : _ : _)+            | Just (quals, base) <- unsnoc segs+            , not (T.null base)+            , all isConid quals ->+                (Just (T.intercalate "." quals), base)+        _ -> (Nothing, name)+  where+    isConid s = maybe False (\(c, rest) -> isUpper c && T.all isIdentChar rest) (T.uncons s)+    unsnoc xs = (,) <$> viaNonEmpty init xs <*> viaNonEmpty last xs+++-- | Given text starting with @(@, the contents up to the matching @)@ and the+-- text after it.+balanced :: Text -> Maybe (Text, Text)+balanced t = do+    rest <- T.stripPrefix "(" t+    let go :: Int -> Int -> String -> Maybe Int+        go _ _ [] = Nothing+        go depth n (c : cs)+            | c == ')' && depth == 0 = Just n+            | c == ')' = go (depth - 1) (n + 1) cs+            | c == '(' = go (depth + 1) (n + 1) cs+            | otherwise = go depth (n + 1) cs+    n <- go 0 0 (toString rest)+    pure (T.take n rest, T.drop (n + 1) rest)+++splitTopCommas :: Text -> [Text]+splitTopCommas = map toText . go (0 :: Int) [] . toString+  where+    go _ acc [] = [reverse acc]+    go depth acc (c : cs)+        | c == ',' && depth == 0 = reverse acc : go depth [] cs+        | c == '(' = go (depth + 1) (c : acc) cs+        | c == ')' = go (depth - 1) (c : acc) cs+        | otherwise = go depth (c : acc) cs+++stripComments :: Text -> Text+stripComments = toText . code . toString+  where+    code [] = []+    code ('{' : '-' : cs) = ' ' : block (1 :: Int) cs+    code ('"' : cs) = '"' : str cs+    code ('\'' : '\\' : cs) = let (lit, rest) = break (== '\'') cs in '\'' : '\\' : lit <> code rest+    code ('\'' : c : '\'' : cs) = '\'' : c : '\'' : code cs+    code s@(c : _)+        | isIdentChar c = let (ident, rest) = span isIdentChar s in ident <> code rest+        | isSymbolChar c =+            let (op, rest) = span isSymbolChar s+            in  if length op >= 2 && all (== '-') op+                    then code (dropWhile (/= '\n') rest)+                    else op <> code rest+    code (c : cs) = c : code cs++    block _ [] = []+    block depth ('-' : '}' : cs)+        | depth == 1 = code cs+        | otherwise = block (depth - 1) cs+    block depth ('{' : '-' : cs) = block (depth + 1) cs+    block depth ('\n' : cs) = '\n' : block depth cs+    block depth (_ : cs) = block depth cs++    str [] = []+    str ('\\' : c : cs) = '\\' : c : str cs+    str ('"' : cs) = '"' : code cs+    str (c : cs) = c : str cs+++isIdentChar :: Char -> Bool+isIdentChar c = isAlphaNum c || c == '_' || c == '\''+++isModuleChar :: Char -> Bool+isModuleChar c = isAlphaNum c || c == '_' || c == '\'' || c == '.'+++isSymbolChar :: Char -> Bool+isSymbolChar c = c `elem` ("!#$%&*+./<=>?@\\^|-~:" :: String)
src/Tricorder/SourceLookup/Tarball.hs view
@@ -5,6 +5,7 @@       TarballOutcome (..)     , obtainTarball     , readModuleMember+    , sourceExtensions        -- * Pure helpers (exposed for testing)     , tarballPath
+ test/Unit/Tricorder/CLI/App/Test/SuiteViewSpec.hs view
@@ -0,0 +1,119 @@+module Unit.Tricorder.CLI.App.Test.SuiteViewSpec (test_SuiteView) where++import Data.Aeson (Value (..), toJSON)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++import Data.Aeson.Key qualified as Key+import Data.Aeson.KeyMap qualified as KeyMap+import Data.Map.Strict qualified as Map+import Tricorder.CLI.Command.TestFilter qualified as TestFilter+import Tricorder.FieldMask.Schema qualified as FieldMask (Schema (..))+import Tricorder.FieldMask.Schema qualified as FieldMask.Schema++import Tricorder.Build.Duration (Duration (..))+import Tricorder.CLI.App.Test.SuiteView+    ( CaseView (..)+    , Outcome (..)+    , Status (..)+    , SuiteView (..)+    , defaultFields+    , schema+    , selectSuites+    )+import Tricorder.Session.TestTarget (TestTarget)++import Tricorder.Build.Test qualified as Test+import Tricorder.Session.TestTarget qualified as TestTarget+++test_SuiteView :: TestTree+test_SuiteView =+    testGroup+        "SuiteView"+        [ testCase "schema lists every field of the JSON encoding" do+            schemaKeys schema @?= jsonKeys (toJSON fullView)+        , testCase "default fields are in the schema" do+            void (FieldMask.Schema.select schema defaultFields) @?= Right ()+        , testGroup "selectSuites" testSelectSuites+        ]+++testSelectSuites :: [TestTree]+testSelectSuites =+    [ testCase "shows all suites when none are requested" do+        names (selectSuites [] TestFilter.All suites)+            @?= Right ["test:errored", "test:failing", "test:passing"]+    , testCase "shows requested suites in the requested order" do+        names (selectSuites [target "test:passing", target "test:failing"] TestFilter.All suites)+            @?= Right ["test:passing", "test:failing"]+    , testCase "fails on suites that do not exist" do+        names (selectSuites [target "test:passing", target "test:nope"] TestFilter.All suites)+            @?= Left (target "test:nope" :| [])+    , testCase "failed-only keeps failed and errored suites" do+        names (selectSuites [] TestFilter.FailedOnly suites) @?= Right ["test:errored", "test:failing"]+    , testCase "failed-only keeps only failed test cases" do+        fmap (fmap (.cases)) (selectSuites [target "test:failing"] TestFilter.FailedOnly suites)+            @?= Right [Just [CaseView "breaks" CaseFailed (Just "boom")]]+    , testCase "counts cases before filtering them" do+        fmap+            (fmap (\s -> (s.passedCases, s.totalCases)))+            (selectSuites [target "test:failing"] TestFilter.FailedOnly suites)+            @?= Right [(Just 1, Just 2)]+    , testCase "errored suites carry their error" do+        fmap (fmap (\s -> (s.status, s.error))) (selectSuites [target "test:errored"] TestFilter.All suites)+            @?= Right [(Errored, Just "did not compile")]+    ]+  where+    names = fmap (fmap (.name))+    suites =+        Test.Suites+            $ Map.fromList+                [ (target "test:passing", completed True [Test.Case "works" Test.Passed])+                ,+                    ( target "test:failing"+                    , completed False [Test.Case "works" Test.Passed, Test.Case "breaks" (Test.Failed "boom")]+                    )+                , (target "test:errored", Test.SuiteErrored $ Test.SuiteError "did not compile")+                ]+    completed passed testCases =+        Test.SuiteCompleted+            $ Test.SuiteCompletion {passed, output = "", testCases, duration = Nothing}+++target :: Text -> TestTarget+target name = fromMaybe (Prelude.error $ "invalid test target: " <> name) $ TestTarget.parse name+++-- | A view with every field set, so every field shows up in its JSON.+fullView :: SuiteView+fullView =+    SuiteView+        { name = "test:suite"+        , status = Failed+        , error = Just "error"+        , passedCases = Just 0+        , totalCases = Just 1+        , durationMs = Just $ Duration 5+        , output = Just "output"+        , cases = Just [CaseView "case" CaseFailed (Just "failure")]+        }+++-- | Keys of a JSON value, nested keys of arrays taken from their first element.+data Keys = Keys [(Text, Maybe Keys)]+    deriving stock (Eq, Show)+++schemaKeys :: FieldMask.Schema -> Keys+schemaKeys (FieldMask.Schema fields) = Keys $ sortOn fst $ second (fmap schemaKeys) <$> fields+++jsonKeys :: Value -> Keys+jsonKeys = \case+    Object obj -> Keys $ sortOn fst [(Key.toText key, nestedKeys value) | (key, value) <- KeyMap.toList obj]+    _ -> Keys []+  where+    nestedKeys = \case+        Array values | Just element <- viaNonEmpty head (toList values) -> Just $ jsonKeys element+        _ -> Nothing
test/Unit/Tricorder/Daemon/BuilderSpec.hs view
@@ -10,12 +10,7 @@ import Tricorder.Build (BuildResult (..), Diagnostic (..), Severity (..)) import Tricorder.Build.Duration (Duration (..)) import Tricorder.Daemon.Builder (NewLoadResult (..), compileBuildResults)-import Tricorder.Daemon.GhciSession.GhciParser-    ( LoadResult (..)-    , LoadedModule (..)-    , extractTitle-    , resolveKnownTargets-    )+import Tricorder.Daemon.Ghci.LoadResult (LoadResult (..)) import Tricorder.Runtime (ProjectRoot (..)) import Tricorder.Session.WatchDirs (WatchDirs (..)) @@ -24,9 +19,7 @@ test_Builder =     testGroup         "Builder"-        [ testGroup "extractTitle" testExtractTitle-        , testGroup "compileBuildResults" testCompileBuildResults-        , testGroup "resolveKnownTargets" testResolveKnownTargets+        [ testGroup "compileBuildResults" testCompileBuildResults         ]  @@ -102,148 +95,6 @@   where     root = ProjectRoot "/"     watchDirs = WatchDirs ["/src"]-------------------------------------------------------------------------------------- resolveKnownTargets tests-----------------------------------------------------------------------------------testResolveKnownTargets :: [TestTree]-testResolveKnownTargets =-    [ testCase "uses :show modules as the primary source for path↔name mapping" do-        let result =-                emptyLr-                    { loadedModules =-                        Map.fromList-                            [-                                ( "/abs/src/Foo.hs"-                                , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}-                                )-                            ]-                    , targetNames = ["Foo"]-                    }-        resolveKnownTargets Map.empty result-            @?= Map.fromList-                [-                    ( "/abs/src/Foo.hs"-                    , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}-                    )-                ]-    , -- Regression test for the stale-results bug. After a failed compile, the-      -- module disappears from :show modules but stays in :show targets. The-      -- prior state's entry must be carried over so the dispatcher continues to-      -- see the file as "known" and issues :reload (not :add) when the user-      -- fixes the error.-      testCase "carries over prior state for targets that are no longer in :show modules" do-        let prev =-                Map.fromList-                    [-                        ( "/abs/src/Foo.hs"-                        , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}-                        )-                    ]-            result =-                emptyLr-                    { loadedModules = Map.empty -- Foo failed to compile-                    , targetNames = ["Foo"] -- but is still a target-                    }-        resolveKnownTargets prev result @?= prev-    , testCase "drops targets that are no longer in :show targets" do-        let prev =-                Map.fromList-                    [-                        ( "/abs/src/Foo.hs"-                        , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}-                        )-                    ]-            result = emptyLr {loadedModules = Map.empty, targetNames = []}-        resolveKnownTargets prev result @?= Map.empty-    , -- Dropped from the path-keyed map because we have no path↔name entry;-      -- the dispatcher still handles them via 'KnownTargetNames'.-      testCase "drops targets that have neither a current :show modules entry nor prior state" do-        let result = emptyLr {loadedModules = Map.empty, targetNames = ["BrandNew"]}-        resolveKnownTargets Map.empty result @?= Map.empty-    ]-  where-    emptyLr =-        LoadResult-            { moduleCount = 0-            , compiledFiles = Set.empty-            , loadedModules = Map.empty-            , targetNames = []-            , diagnostics = []-            }-------------------------------------------------------------------------------------- extractTitle tests-----------------------------------------------------------------------------------testExtractTitle :: [TestTree]-testExtractTitle =-    [ testCase "returns empty string for empty message" do-        extractTitle [] @?= ""-    , -- New GHC style: header ends with [GHC-XXXXX], content on body lines.-      -- Captured from GHC 9.10.2 with -Weverything.-      testCase "extracts first body line for error with [GHC-XXXXX] code" do-        extractTitle-            [ "src/Tricorder/Config.hs:39:20: error: [GHC-83865]"-            , "    \8226 Couldn't match expected type 'Int' with actual type 'Bool'"-            , "    \8226 In the expression: True"-            , "      In an equation for '_deliberateError': _deliberateError = True"-            , "   |"-            , "39 | _deliberateError = True"-            , "   |                    ^^^^"-            ]-            @?= "\8226 Couldn't match expected type 'Int' with actual type 'Bool'"-    , testCase "extracts first body line for warning with [GHC-XXXXX] [-Wfoo] codes" do-        extractTitle-            [ "src/Tricorder/Config.hs:38:26: warning: [GHC-55631] [-Wmissing-deriving-strategies]"-            , "    No deriving strategy specified. Did you want stock, newtype, or anyclass?"-            , "   |"-            , "38 | data TestWarn = TestWarn deriving (Eq)"-            , "   |                          ^^^^^^^^^^^^^"-            ]-            @?= "No deriving strategy specified. Did you want stock, newtype, or anyclass?"-    , -- Old GHC style: message text is inline on the header line.-      testCase "extracts inline content for old-style single-line error" do-        extractTitle ["GHCi.hs:70:1: error: Parse error: naked expression at top level"]-            @?= "Parse error: naked expression at top level"-    , testCase "extracts inline content for old-style Warning (capital W)" do-        extractTitle ["GHCi.hs:81:1: Warning: Defined but not used: \8216foo\8217"]-            @?= "Defined but not used: \8216foo\8217"-    , -- Multi-line without any inline message: position-only or "Warning:" header.-      testCase "extracts first body line when header has position only" do-        extractTitle-            [ "GHCi.hs:72:13:"-            , "    No instance for (Num ([String] -> [String]))"-            , "      arising from the literal '1'"-            ]-            @?= "No instance for (Num ([String] -> [String]))"-    , testCase "extracts first body line when header ends with 'Warning:'" do-        extractTitle-            [ "/src/TrieSpec.hs:(192,7)-(193,76): Warning:"-            , "    A do-notation statement discarded a result of type '[()]'"-            ]-            @?= "A do-notation statement discarded a result of type '[()]'"-    , -- Source display lines (pipe/caret) must be skipped.-      testCase "skips source display lines when scanning body" do-        extractTitle-            [ "file.hs:1:1: error: [GHC-12345]"-            , "   |"-            , "1 | foo bar"-            , "   |     ^^^"-            , "    actual content here"-            ]-            @?= "actual content here"-    , -- ANSI-escaped header (colour output): strip escapes before searching.-      testCase "handles ANSI-escaped headers" do-        extractTitle-            [ "\ESC[;1msrc/Types.hs:11:1: \ESC[35mwarning:\ESC[0m \ESC[35m[-Wunused-imports]\ESC[0m"-            , "    The import of 'Data.Data' is redundant"-            ]-            @?= "The import of 'Data.Data' is redundant"-    ]   --------------------------------------------------------------------------------
test/Unit/Tricorder/Daemon/DispatchSpec.hs view
@@ -14,7 +14,8 @@     , mergeDiagnostics     , preserveFailureVisibility     )-import Tricorder.Daemon.GhciSession.GhciParser (LoadResult (..), collectResult)+import Tricorder.Daemon.Ghci.LoadResult (LoadResult (..))+import Tricorder.Daemon.Ghci.Result (collectResult) import Tricorder.Session.WatchDirs (WatchDirs (..))  
+ test/Unit/Tricorder/Daemon/Ghci/LoadResultSpec.hs view
@@ -0,0 +1,87 @@+module Unit.Tricorder.Daemon.Ghci.LoadResultSpec (test_LoadResult) where++import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++import Data.Map.Strict qualified as Map+import Data.Set qualified as Set++import Tricorder.Daemon.Ghci.LoadResult (LoadResult (..))+import Tricorder.Daemon.Ghci.LoadedModule (LoadedModule (..))++import Tricorder.Daemon.Ghci.LoadResult qualified as LoadResult+++test_LoadResult :: TestTree+test_LoadResult =+    testGroup+        "LoadResult"+        [ testGroup "resolveKnownTargets" testResolveKnownTargets+        ]+++testResolveKnownTargets :: [TestTree]+testResolveKnownTargets =+    [ testCase "uses :show modules as the primary source for path↔name mapping" do+        let result =+                emptyLr+                    { loadedModules =+                        Map.fromList+                            [+                                ( "/abs/src/Foo.hs"+                                , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}+                                )+                            ]+                    , targetNames = ["Foo"]+                    }+        LoadResult.resolveKnownTargets Map.empty result+            @?= Map.fromList+                [+                    ( "/abs/src/Foo.hs"+                    , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}+                    )+                ]+    , -- Regression test for the stale-results bug. After a failed compile, the+      -- module disappears from :show modules but stays in :show targets. The+      -- prior state's entry must be carried over so the dispatcher continues to+      -- see the file as "known" and issues :reload (not :add) when the user+      -- fixes the error.+      testCase "carries over prior state for targets that are no longer in :show modules" do+        let prev =+                Map.fromList+                    [+                        ( "/abs/src/Foo.hs"+                        , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}+                        )+                    ]+            result =+                emptyLr+                    { loadedModules = Map.empty -- Foo failed to compile+                    , targetNames = ["Foo"] -- but is still a target+                    }+        LoadResult.resolveKnownTargets prev result @?= prev+    , testCase "drops targets that are no longer in :show targets" do+        let prev =+                Map.fromList+                    [+                        ( "/abs/src/Foo.hs"+                        , LoadedModule {relPath = "./src/Foo.hs", moduleName = "Foo"}+                        )+                    ]+            result = emptyLr {loadedModules = Map.empty, targetNames = []}+        LoadResult.resolveKnownTargets prev result @?= Map.empty+    , -- Dropped from the path-keyed map because we have no path↔name entry;+      -- the dispatcher still handles them via 'KnownTargetNames'.+      testCase "drops targets that have neither a current :show modules entry nor prior state" do+        let result = emptyLr {loadedModules = Map.empty, targetNames = ["BrandNew"]}+        LoadResult.resolveKnownTargets Map.empty result @?= Map.empty+    ]+  where+    emptyLr =+        LoadResult+            { moduleCount = 0+            , compiledFiles = Set.empty+            , loadedModules = Map.empty+            , targetNames = []+            , diagnostics = []+            }
+ test/Unit/Tricorder/Daemon/Ghci/OutputSpec.hs view
@@ -0,0 +1,78 @@+module Unit.Tricorder.Daemon.Ghci.OutputSpec (test_Output) where++import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++import Tricorder.Daemon.Ghci.Output (parseShowModules, parseShowTargets)+++test_Output :: TestTree+test_Output =+    testGroup+        "Output"+        [ testGroup "parseShowModules" testParseShowModules+        , testGroup "parseShowTargets" testParseShowTargets+        ]+++testParseShowModules :: [TestTree]+testParseShowModules =+    [ testGroup "typical output" testShowModules+    , testGroup "empty / blank input" testShowModulesEmpty+    ]+++testShowModules :: [TestTree]+testShowModules =+    [ testCase "parses typical :show modules output" do+        let input =+                [ "Tricorder.Build     ( src/Tricorder.Build.hs, interpreted )"+                , "Tricorder.Session        ( src/Tricorder/Session.hs, interpreted )"+                , "Main                     ( app/Main.hs, interpreted )"+                ]+        parseShowModules input+            @?= [ ("Tricorder.Build", "src/Tricorder.Build.hs")+                , ("Tricorder.Session", "src/Tricorder/Session.hs")+                , ("Main", "app/Main.hs")+                ]+    , testCase "parses absolute paths" do+        parseShowModules ["Lib ( /home/user/project/src/Lib.hs, interpreted )"]+            @?= [("Lib", "/home/user/project/src/Lib.hs")]+    , testCase "strips ANSI codes before parsing" do+        parseShowModules ["\ESC[1mMain\ESC[0m                     ( app/Main.hs, interpreted )"]+            @?= [("Main", "app/Main.hs")]+    ]+++testShowModulesEmpty :: [TestTree]+testShowModulesEmpty =+    [ testCase "returns empty list for empty input" do+        parseShowModules [] @?= []+    , testCase "returns empty list for blank lines" do+        parseShowModules ["", "   ", "\t"] @?= []+    , testCase "skips lines without '( '" do+        parseShowModules ["just some random text"] @?= []+    ]+++testParseShowTargets :: [TestTree]+testParseShowTargets =+    [ testCase "parses module names emitted by cabal repl --enable-multi-repl" do+        parseShowTargets+            [ "Atelier.Effects.Cache"+            , "Atelier.Effects.Chan"+            , "Paths_tricorder"+            ]+            @?= ["Atelier.Effects.Cache", "Atelier.Effects.Chan", "Paths_tricorder"]+    , testCase "parses file-path targets emitted by plain ghci" do+        parseShowTargets ["src/Foo.hs", "test/Bar.hs"]+            @?= ["src/Foo.hs", "test/Bar.hs"]+    , testCase "strips the leading '*' marker for the active interactive target" do+        parseShowTargets ["*Main", "Foo.Bar"] @?= ["Main", "Foo.Bar"]+    , testCase "strips ANSI escape sequences" do+        parseShowTargets ["\ESC[1mFoo.Bar\ESC[0m"] @?= ["Foo.Bar"]+    , testCase "skips blank and whitespace-only lines" do+        parseShowTargets ["", "   ", "\t", "Real.Target"] @?= ["Real.Target"]+    , testCase "returns empty list for empty input" do+        parseShowTargets [] @?= []+    ]
+ test/Unit/Tricorder/Daemon/Ghci/ProcessSpec.hs view
@@ -0,0 +1,564 @@+module Unit.Tricorder.Daemon.Ghci.ProcessSpec (test_Process) where++import Atelier.Effects.Conc (runConc)+import Atelier.Effects.Delay (runDelay)+import Atelier.Effects.File (runFile)+import Atelier.Effects.Log (Message (..), Severity (..), runLogNoOp, runLogWriter)+import Atelier.Effects.Process (runProcessIO, terminateProcessGroup, withProcessGroup)+import Atelier.Effects.Process.Internal (RunningProcess (..))+import Atelier.Effects.Timeout (runTimeout)+import Atelier.Time (Millisecond)+import Control.Concurrent (threadDelay)+import Control.Concurrent.STM (newTVarIO)+import Control.Exception (IOException, catch)+import Data.Char (isDigit)+import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)+import Data.Time.Units (Second)+import Effectful (runEff)+import Effectful.Concurrent (runConcurrent)+import Effectful.Exception (trySync)+import Effectful.Writer.Static.Shared (runWriter)+import System.IO (hGetLine)+import System.Posix.Signals (nullSignal, sigKILL, signalProcess)+import System.Process.Typed+    ( createPipe+    , getStderr+    , getStdin+    , getStdout+    , setCreateGroup+    , setStderr+    , setStdin+    , setStdout+    , shell+    , startProcess+    , stopProcess+    , waitExitCode+    )+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertFailure, testCase, (@?), (@?=))++import Atelier.Effects.Conc qualified as Conc+import Atelier.Effects.Delay qualified as Delay+import Atelier.Effects.File qualified as File+import Atelier.Effects.Process qualified as AProc+import Data.Text qualified as T+import System.Process qualified as Process+import System.Timeout qualified++import Tricorder.Daemon.Ghci.InterruptDecision (InterruptDecision (..))+import Tricorder.Daemon.Ghci.Process (Error (..), Process (..), UnexpectedExit (..))+import Tricorder.Daemon.Ghci.Session.State (State (..))++import Tricorder.Daemon.Ghci.InterruptDecision qualified as InterruptDecision+import Tricorder.Daemon.Ghci.Process qualified as Process+++test_Process :: TestTree+test_Process =+    testGroup+        "Process"+        [ testGroup "decideInterrupt" testDecideInterrupt+        , testGroup "drainUntil" testDrainUntil+        , testGroup "execGhci" testExecGhciScope+        , testGroup "execGhci (stale marker desync)" testExecGhciStaleMarker+        , testGroup "execGhci (sync marker scope independence)" testSyncMarkerScopeIndependent+        , testGroup "waitForBannerOrFail" testWaitForBannerOrFail+        , testGroup "withProcessGroup (process group)" testWithProcessGroupCleanup+        , testGroup "terminateProcessGroup (process group)" testTerminateProcessGroup+        ]+++-- | Mirrors the private 'markerFor' helper (not exported), using the same+-- reconstruction as 'testExecGhciStaleMarker' below.+finishMarker :: Int -> Text+finishMarker n = "#~TRI-FINISH-" <> show n <> "~#"+++testDrainUntil :: [TestTree]+testDrainUntil =+    [ testCase "returns accumulated non-marker lines in order and stops at the marker" do+        (r, w) <- Process.createPipe+        (result, _msgs) <-+            runEff+                . runWriter @[Message]+                . runLogWriter+                . runFile+                $ do+                    for_ ["line1", "line2", finishMarker 1, "line3"] (File.hPutTextLn w)+                    File.hClose w+                    Process.drainUntil r (finishMarker 1) (\_ -> pure ())+        result @?= ["line1", "line2"]+    , testCase "streams each non-marker line to onLine, in order, before returning" do+        (r, w) <- Process.createPipe+        seenRef <- newIORef []+        (result, _msgs) <-+            runEff+                . runWriter @[Message]+                . runLogWriter+                . runFile+                $ do+                    for_ ["a", "b", "c", finishMarker 2] (File.hPutTextLn w)+                    File.hClose w+                    Process.drainUntil r (finishMarker 2) (\l -> liftIO $ modifyIORef' seenRef (l :))+        seen <- reverse <$> readIORef seenRef+        seen @?= ["a", "b", "c"]+        result @?= seen+    , testCase "skips a stale marker with a different suffix and keeps draining" do+        (r, w) <- Process.createPipe+        (result, _msgs) <-+            runEff+                . runWriter @[Message]+                . runLogWriter+                . runFile+                $ do+                    -- 'finishMarker 5' is a leftover from a prior, interrupted+                    -- command; the drain waiting for 'finishMarker 9' must+                    -- skip it rather than stopping.+                    for_ ["before", finishMarker 5, "after", finishMarker 9] (File.hPutTextLn w)+                    File.hClose w+                    Process.drainUntil r (finishMarker 9) (\_ -> pure ())+        result @?= ["before", "after"]+    , testCase+        "throws UnexpectedExit with ALL accumulated lines (in order) on EOF, not just the last one"+        do+            (r, w) <- Process.createPipe+            (outcome, _msgs) <-+                runEff+                    . runWriter @[Message]+                    . runLogWriter+                    . runFile+                    $ do+                        for_ ["first", "second", "third"] (File.hPutTextLn w)+                        File.hClose w -- EOF before the marker ever arrives+                        trySync (Process.drainUntil r (finishMarker 1) (\_ -> pure ()))+            case outcome of+                Right ls -> assertFailure ("expected UnexpectedExit, got: " <> show ls)+                Left ex -> case fromException ex of+                    Just (UnexpectedExit (MkUnexpectedExit m ls)) -> do+                        m @?= finishMarker 1+                        ls+                            @?= "Reached EOF before reading marker from GHCi.\nWas looking for marker '#~TRI-FINISH-1~#', but no such marker was found.\nAccumulated output from GHCi so far:\nfirst\nsecond\nthird"+                    other -> assertFailure ("expected UnexpectedExit, got: " <> show other)+    , testCase "throws UnexpectedExit with no lines when EOF is reached immediately" do+        (r, w) <- Process.createPipe+        (outcome, _msgs) <-+            runEff+                . runWriter @[Message]+                . runLogWriter+                . runFile+                $ do+                    File.hClose w+                    trySync (Process.drainUntil r (finishMarker 1) (\_ -> pure ()))+        case outcome of+            Right ls -> assertFailure ("expected UnexpectedExit, got: " <> show ls)+            Left ex -> case fromException ex of+                Just (UnexpectedExit (MkUnexpectedExit m ls)) -> do+                    m @?= finishMarker 1+                    let expected =+                            "Reached EOF before reading marker from GHCi.\nWas looking for marker '#~TRI-FINISH-1~#', but no such marker was found.\nGHCi returned no output before we reached what we believe is EOF.\nGot the following exception when attempting to read from GHCi:\n<file descriptor:"+                    (expected `T.isPrefixOf` ls)+                        @? ( "error message does not start with expected prefix\nExpected:\n"+                                <> show expected+                                <> "\nActual:\n"+                                <> show ls+                           )+                other -> assertFailure ("expected UnexpectedExit, got: " <> show other)+    , testCase+        "logs an ERROR mentioning the missing marker and the exception when EOF is reached with no output"+        do+            (r, w) <- Process.createPipe+            (_outcome, msgs) <-+                runEff+                    . runWriter @[Message]+                    . runLogWriter+                    . runFile+                    $ do+                        File.hClose w+                        trySync (Process.drainUntil r (finishMarker 3) (\_ -> pure ()))+            case filter (\m -> m.severity == ERROR) msgs of+                [] -> assertFailure "expected an ERROR log message"+                (logMsg : _) -> do+                    (finishMarker 3 `T.isInfixOf` logMsg.text) @?= True+                    ("GHCi returned no output" `T.isInfixOf` logMsg.text) @?= True+    , testCase "logs an ERROR including the accumulated output when EOF is reached mid-output" do+        (r, w) <- Process.createPipe+        (_outcome, msgs) <-+            runEff+                . runWriter @[Message]+                . runLogWriter+                . runFile+                $ do+                    for_ ["oops-line-1", "oops-line-2"] (File.hPutTextLn w)+                    File.hClose w+                    trySync (Process.drainUntil r (finishMarker 4) (\_ -> pure ()))+        case filter (\m -> m.severity == ERROR) msgs of+            [] -> assertFailure "expected an ERROR log message"+            (logMsg : _) -> do+                (finishMarker 4 `T.isInfixOf` logMsg.text) @?= True+                ("oops-line-1" `T.isInfixOf` logMsg.text) @?= True+                ("oops-line-2" `T.isInfixOf` logMsg.text) @?= True+    ]+++-- | Regression for the touch-during-reload desync. Interrupting a *Busy* GHCi+-- (a reload in flight) leaves a stale sync marker in the stdout/stderr buffers+-- ahead of the next command's real output. Because 'drainUntil' used to stop on+-- ANY marker-prefix line, the next 'execGhci' matched that stale marker and+-- returned *before its command ran* — surfacing as @All good. (0 modules)@ (or,+-- on the other timing, a hang). 'execGhci' must skip markers that aren't its+-- own and stop only on the marker it is waiting for.+testExecGhciStaleMarker :: [TestTree]+testExecGhciStaleMarker =+    [ testCase "skips a stale leftover marker and returns the command's real output" do+        (stdinR, stdinW) <- Process.createPipe+        (stdoutR, stdoutW) <- Process.createPipe+        (stderrR, stderrW) <- Process.createPipe+        -- A dummy process handle to fill the record; 'execGhci' never uses it.+        p <-+            startProcess+                $ setStdin createPipe+                $ setStdout createPipe+                $ setStderr createPipe+                $ shell "true"+        _ <- waitExitCode p+        stateVar <- newTVarIO (Idle 9)+        let gp =+                Process+                    { stdin = stdinW+                    , stdout = stdoutR+                    , stderr = stderrR+                    , handle = RunningProcess p+                    , stateVar+                    }+            -- Mirrors 'markerFor': "#~TRI-FINISH-<n>~#".+            marker n = "#~TRI-FINISH-" <> show (n :: Int) <> "~#" :: Text+        result <-+            runEff+                . runConcurrent+                . runTimeout+                . runDelay+                . runFile+                . runLogNoOp+                . runConc+                $ do+                    -- A stale 'marker 5' (left by a prior interrupted reload)+                    -- precedes the fresh command's real output + its 'marker 9'+                    -- (state is 'Idle 9', so 'execGhci' waits for marker 9).+                    for_ [marker 5, "out-line", marker 9] (File.hPutTextLn stdoutW)+                    for_ [marker 5, "err-line", marker 9] (File.hPutTextLn stderrW)+                    File.hClose stdoutW+                    File.hClose stderrW+                    -- Keep the stdin read-end alive across the write inside+                    -- 'execGhci' (otherwise it is GC-finalised → broken pipe).+                    r <- Process.exec gp "reload" (\_ -> pure ())+                    File.hClose stdinR+                    pure r+        _ <- (Right <$> stopProcess p) `catch` \(_ :: SomeException) -> pure (Left ())+        result @?= ["out-line", "err-line"]+    ]+++-- | Root-cause regression for the "stuck Building…" stall. A SIGINT-interrupted+-- ':reload' empties GHCi's interactive scope — it drops the implicit+-- @import Prelude@ (verified against ghci 9.10). A sync marker built from bare+-- Prelude names then fails to run instead of printing: @putStrLn@ is no longer+-- in scope, and neither is the @>>@ operator. The marker never appears, so+-- 'drainUntil' blocks until the watchdog fires. The marker must therefore use+-- only fully-qualified 'System.IO.hPutStrLn' statements (which survive the+-- emptied scope), one per stream, with no bare names or operators.+--+-- We assert on exactly what 'execGhci' writes to GHCi's stdin.+testSyncMarkerScopeIndependent :: [TestTree]+testSyncMarkerScopeIndependent =+    [ testCase "writes the finish marker using only fully-qualified names (no bare putStrLn / >>)" do+        (stdinR, stdinW) <- Process.createPipe+        (stdoutR, stdoutW) <- Process.createPipe+        (stderrR, stderrW) <- Process.createPipe+        p <-+            startProcess+                $ setStdin createPipe+                $ setStdout createPipe+                $ setStderr createPipe+                $ shell "true"+        _ <- waitExitCode p+        stateVar <- newTVarIO (Idle 9)+        let gp =+                Process+                    { stdin = stdinW+                    , stdout = stdoutR+                    , stderr = stderrR+                    , handle = RunningProcess p+                    , stateVar+                    }+            marker = "#~TRI-FINISH-9~#" :: Text+        written <-+            runEff+                . runConcurrent+                . runTimeout+                . runDelay+                . runFile+                . runLogNoOp+                . runConc+                $ do+                    -- Pre-seed the marker on both streams so the drain returns+                    -- immediately; we only care about what was written to stdin.+                    File.hPutTextLn stdoutW marker+                    File.hPutTextLn stderrW marker+                    File.hClose stdoutW+                    File.hClose stderrW+                    _ <- Process.exec gp ":reload" (\_ -> pure ())+                    File.hClose stdinW+                    let readAll acc =+                            trySync (File.hGetLine stdinR) >>= \case+                                Left (_ :: SomeException) -> pure (reverse acc)+                                Right l -> readAll (l : acc)+                    readAll []+        _ <- (Right <$> stopProcess p) `catch` \(_ :: SomeException) -> pure (Left ())+        let blob = T.intercalate "\n" written+        (" >> " `T.isInfixOf` blob) @?= False+        ("System.IO.hPutStrLn System.IO.stdout" `T.isInfixOf` blob) @?= True+        ("System.IO.hPutStrLn System.IO.stderr" `T.isInfixOf` blob) @?= True+    ]+++-- | Regression: when the build command exits before printing a GHCi banner,+-- 'waitForBannerOrFail' must surface the *complete* stderr output in the+-- 'StartupFailed' error. The original implementation snapshotted the captured+-- lines as soon as the process exited (it waited on 'waitExitCode'), racing+-- the concurrent stderr drain — so a burst of error lines still buffered in+-- the pipe was truncated, and the real cabal/build failure was lost.+testWaitForBannerOrFail :: [TestTree]+testWaitForBannerOrFail =+    [ testCase "captures the full stderr output when the command exits before the banner" do+        let lineCount = 200 :: Int+            lastLine = "err line " <> show lineCount+        -- The banner and error streams are pipes we drive ourselves, so the+        -- timing is deterministic rather than a race against the OS pipe buffer.+        (bannerOut, bannerOutW) <- Process.createPipe+        (errR, errW) <- Process.createPipe+        result <-+            runEff+                . runConcurrent+                . runTimeout+                . runDelay+                . runFile+                . runConc+                $ do+                    -- No banner will ever arrive: close the write end so the+                    -- wait sees EOF at once and takes the "command exited"+                    -- failure branch.+                    File.hClose bannerOutW+                    -- Producer: pause long enough that a snapshot-at-exit reads+                    -- an empty buffer, THEN stream the whole error log and+                    -- close so the drain sees EOF. A correct implementation+                    -- awaits that drain before reading the captured lines.+                    _ <- Conc.fork do+                        Delay.wait (30 :: Millisecond)+                        for_ [1 .. lineCount] \i ->+                            File.hPutTextLn errW ("err line " <> show i :: Text)+                        File.hClose errW+                    trySync (Process.waitForBannerOrFail (5 :: Second) bannerOut errR)+        case result of+            Right () -> assertFailure "expected waitForBannerOrFail to throw a startup error"+            Left ex -> case fromException ex of+                Just (StartupFailed msg) ->+                    (lastLine `T.isInfixOf` msg) @?= True+                other ->+                    assertFailure ("expected StartupFailed, got: " <> show other)+    ]+++-- | Regression for orphaned/zombie build subprocesses on restart and shutdown.+--+-- The original leak was on the /graceful/ path: GHCi exits cleanly on @:quit@,+-- so its leader is reaped, yet the build subprocesses sharing its group linger.+-- 'withProcessGroup' must still sweep the whole group even after the leader has+-- gone. We simulate it with a leader that forks a long-lived child sharing its+-- group and exits on stdin input (mirroring @:quit@); after 'withProcessGroup'+-- returns, the child must be gone.+testWithProcessGroupCleanup :: [TestTree]+testWithProcessGroupCleanup =+    [ testCase "terminates the whole group on exit, even after the leader has exited" do+        childPidRef <- newIORef (Nothing :: Maybe Int)+        let scenario =+                runEff+                    . runConcurrent+                    . runTimeout+                    . runDelay+                    . runFile+                    . runConc+                    . runProcessIO+                    $ withProcessGroup procConfig \p -> do+                        -- First stdout line is the long-lived child's pid.+                        line <- File.hGetLine (AProc.getStdout p)+                        liftIO $ writeIORef childPidRef (parsePid (T.unpack line))+                        -- Let the leader exit and reap it, so the cleanup runs+                        -- with the leader already gone — the path the bug needed.+                        File.hPutTextLn (AProc.getStdin p) ""+                        File.hFlush (AProc.getStdin p)+                        void $ AProc.waitExitCode p+        -- Hard wall-clock bound: a regression must surface as a failed+        -- assertion, never as a hang that stalls the whole suite.+        outcome <- System.Timeout.timeout (8_000_000) scenario+        case outcome of+            Nothing -> assertFailure "test timed out (process did not settle)"+            Just () ->+                readIORef childPidRef >>= \case+                    Nothing -> assertFailure "could not capture the child pid"+                    Just childPid -> do+                        died <- waitForProcessDeath childPid+                        -- Never leak the child if the assertion fails.+                        ignoring (signalProcess sigKILL (fromIntegral childPid))+                        died @?= True+    ]+  where+    procConfig =+        setStdin createPipe+            $ setStdout createPipe+            $ setStderr createPipe+            $ shell "sleep 30 & echo \"$!\"; read _quit"+++-- | 'terminateProcessGroup' must kill the whole group when called mid-flight+-- (the leader still alive) — the explicit early-termination path the test+-- runner uses to abort a one-shot @cabal repl test:…@ from another thread.+testTerminateProcessGroup :: [TestTree]+testTerminateProcessGroup =+    [ testCase "kills the whole group, not just the leader, mid-flight" do+        outcome <- System.Timeout.timeout (8_000_000) do+            p <-+                startProcess+                    $ setStdin createPipe+                    $ setStdout createPipe+                    $ setStderr createPipe+                    $ setCreateGroup True+                    $ shell "sleep 30 & echo \"$!\"; read _quit"+            childLine <- hGetLine (getStdout p)+            case parsePid childLine of+                Nothing -> do+                    ignoring (stopProcess p)+                    assertFailure ("could not parse child pid from: " <> show childLine)+                Just childPid -> do+                    runEff . runProcessIO $ terminateProcessGroup (RunningProcess p)+                    died <- waitForProcessDeath childPid+                    -- Never leak the child if the assertion fails.+                    ignoring (signalProcess sigKILL (fromIntegral childPid))+                    pure died+        outcome @?= Just True+    ]+++-- | Parse a pid printed on its own line (tolerating surrounding whitespace).+parsePid :: String -> Maybe Int+parsePid = readMaybe . takeWhile isDigit . dropWhile (not . isDigit)+++-- | Swallow any exception from a best-effort cleanup action.+ignoring :: IO () -> IO ()+ignoring act = act `catch` \(_ :: SomeException) -> pure ()+++-- | Poll for up to ~3s for the given pid to disappear from the process table.+-- @signalProcess nullSignal@ is a liveness probe: it throws once the process is+-- gone (and reaped by init after being orphaned).+waitForProcessDeath :: Int -> IO Bool+waitForProcessDeath pid = go (60 :: Int)+  where+    go 0 = not <$> alive+    go n = do+        a <- alive+        if not a then pure True else threadDelay 50_000 >> go (n - 1)+    alive =+        (signalProcess nullSignal (fromIntegral pid) >> pure True)+            `catch` \(_ :: IOException) -> pure False+++testDecideInterrupt :: [TestTree]+testDecideInterrupt =+    -- Regression: an idle GHCi must not be SIGINT'd, since the matching+    -- sync-marker write would leave a stale marker line in stdout/stderr+    -- that the next 'execGhci' drain would match instead of the fresh one,+    -- desyncing the protocol and reporting "0 modules" or hanging.+    [ testCase "is a no-op when the session is Idle" do+        InterruptDecision.decide (Idle 7) @?= (Idle 7, NoOpIdle)+    , testCase "preserves the counter for any Idle state" do+        InterruptDecision.decide (Idle 0) @?= (Idle 0, NoOpIdle)+        InterruptDecision.decide (Idle 42) @?= (Idle 42, NoOpIdle)+    , testCase "advances to Idle (n+1) and emits SendInterruptFor n when Busy" do+        InterruptDecision.decide (Busy 7) @?= (Idle 8, SendInterruptFor 7)+    , testCase "advances correctly from Busy 0" do+        InterruptDecision.decide (Busy 0) @?= (Idle 1, SendInterruptFor 0)+    ]+++-- | Pins down the 'Conc.scoped' fix in 'execGhci': when the drain forks+-- raise 'UnexpectedExit' (because the underlying process exited and EOF'd+-- the pipes), the exception must be CONTAINED inside 'execGhci' and+-- surfaced via the caller's 'trySync' — not propagated to the ambient+-- 'Conc.scoped' that called 'execGhci'.+--+-- Without the inner 'Conc.scoped' in 'execGhci', Ki propagates an+-- exception from a forked thread to its owning scope. If 'execGhci' forks+-- its drains directly into the ambient scope (the original bug), the+-- ambient scope is torn down — siblings die, the whole builder cycle+-- unwinds, and the daemon ends up in the "Restarting builder..." state+-- the user observed.+testExecGhciScope :: [TestTree]+testExecGhciScope =+    -- Spawn a real subprocess that exits immediately ('true'). Its+    -- stdout/stderr pipes EOF as soon as the child exits, which makes+    -- 'drainUntil' inside 'execGhci' throw 'UnexpectedExit' — exactly the+    -- mid-command termination path the fix exists to handle.+    [ testCase "contains drain exceptions inside its own scope so siblings survive" do+        p <-+            startProcess+                $ setStdin createPipe+                $ setStdout createPipe+                $ setStderr createPipe+                $ shell "true"+        -- Wait for the child to actually exit so the pipes are EOF before+        -- 'execGhci' starts draining (otherwise the drain blocks).+        _ <- waitExitCode p+        stateVar <- newTVarIO (Idle 0)+        let gp =+                Process+                    { stdin = getStdin p+                    , stdout = getStdout p+                    , stderr = getStderr p+                    , handle = RunningProcess p+                    , stateVar+                    }+        siblingDoneRef <- newIORef False+        result <-+            runEff+                . runConcurrent+                . runTimeout+                . runDelay+                . runFile+                . runLogNoOp+                . runConc+                $ Conc.scoped do+                    -- A sibling fork in the SAME ambient scope. If the bug+                    -- regresses, the drain exception will tear this scope+                    -- down and the sibling will be cancelled before it can+                    -- flip the ref.+                    sibling <- Conc.fork do+                        Delay.wait (50 :: Millisecond)+                        liftIO (writeIORef siblingDoneRef True)+                    -- Drive 'execGhci' on a dead process; the drains should+                    -- raise 'UnexpectedExit', which 'trySync' must catch+                    -- here rather than letting Ki tear down the scope.+                    execResult <- trySync (Process.exec gp "cmd" (\_ -> pure ()))+                    -- Wait for the sibling to run.+                    Conc.await sibling+                    pure execResult+        -- stopProcess flushes the buffered command+marker to a pipe whose+        -- read end is already closed, which raises ResourceVanished. The+        -- subprocess has long since exited; just swallow the cleanup error.+        _ <- (Right <$> stopProcess p) `catch` \(_ :: SomeException) -> pure (Left ())+        siblingDone <- readIORef siblingDoneRef+        siblingDone @?= True+        case result of+            Right _ -> assertFailure "expected execGhci to raise UnexpectedExit"+            Left _ -> pure ()+    ]
+ test/Unit/Tricorder/Daemon/Ghci/ResultSpec.hs view
@@ -0,0 +1,477 @@+module Unit.Tricorder.Daemon.Ghci.ResultSpec (test_Result) where++import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?), (@?=))++import Tricorder.Build (Severity (..))+import Tricorder.Daemon.Ghci.LoadResult (LoadResult (..))+import Tricorder.Daemon.Ghci.Loading (Loading (..))+import Tricorder.Daemon.Ghci.Message (Message (..))+import Tricorder.Daemon.Ghci.Position (Position (..))+import Tricorder.Daemon.Ghci.Result (collectResult, collectResultCustom, extractTitle, parseReload)++import Tricorder.Build qualified as Build.Diagnostic+import Tricorder.Daemon.Ghci.Load qualified as Load+import Tricorder.Daemon.Ghci.LoadOutcome qualified as LoadOutcome+import Tricorder.Daemon.Ghci.Severity qualified as Severity+++test_Result :: TestTree+test_Result =+    testGroup+        "Result"+        [ testGroup "extractTitle" testExtractTitle+        , testGroup "parseReload" testParseReload+        , testGroup "collectResultCustom" testCollectResultCustom+        , testGroup "collectResult" testCollectResult+        ]+++testExtractTitle :: [TestTree]+testExtractTitle =+    [ testCase "returns empty string for empty message" do+        extractTitle [] @?= ""+    , -- New GHC style: header ends with [GHC-XXXXX], content on body lines.+      -- Captured from GHC 9.10.2 with -Weverything.+      testCase "extracts first body line for error with [GHC-XXXXX] code" do+        extractTitle+            [ "src/Tricorder/Config.hs:39:20: error: [GHC-83865]"+            , "    \8226 Couldn't match expected type 'Int' with actual type 'Bool'"+            , "    \8226 In the expression: True"+            , "      In an equation for '_deliberateError': _deliberateError = True"+            , "   |"+            , "39 | _deliberateError = True"+            , "   |                    ^^^^"+            ]+            @?= "\8226 Couldn't match expected type 'Int' with actual type 'Bool'"+    , testCase "extracts first body line for warning with [GHC-XXXXX] [-Wfoo] codes" do+        extractTitle+            [ "src/Tricorder/Config.hs:38:26: warning: [GHC-55631] [-Wmissing-deriving-strategies]"+            , "    No deriving strategy specified. Did you want stock, newtype, or anyclass?"+            , "   |"+            , "38 | data TestWarn = TestWarn deriving (Eq)"+            , "   |                          ^^^^^^^^^^^^^"+            ]+            @?= "No deriving strategy specified. Did you want stock, newtype, or anyclass?"+    , -- Old GHC style: message text is inline on the header line.+      testCase "extracts inline content for old-style single-line error" do+        extractTitle ["GHCi.hs:70:1: error: Parse error: naked expression at top level"]+            @?= "Parse error: naked expression at top level"+    , testCase "extracts inline content for old-style Warning (capital W)" do+        extractTitle ["GHCi.hs:81:1: Warning: Defined but not used: \8216foo\8217"]+            @?= "Defined but not used: \8216foo\8217"+    , -- Multi-line without any inline message: position-only or "Warning:" header.+      testCase "extracts first body line when header has position only" do+        extractTitle+            [ "GHCi.hs:72:13:"+            , "    No instance for (Num ([String] -> [String]))"+            , "      arising from the literal '1'"+            ]+            @?= "No instance for (Num ([String] -> [String]))"+    , testCase "extracts first body line when header ends with 'Warning:'" do+        extractTitle+            [ "/src/TrieSpec.hs:(192,7)-(193,76): Warning:"+            , "    A do-notation statement discarded a result of type '[()]'"+            ]+            @?= "A do-notation statement discarded a result of type '[()]'"+    , -- Source display lines (pipe/caret) must be skipped.+      testCase "skips source display lines when scanning body" do+        extractTitle+            [ "file.hs:1:1: error: [GHC-12345]"+            , "   |"+            , "1 | foo bar"+            , "   |     ^^^"+            , "    actual content here"+            ]+            @?= "actual content here"+    , -- ANSI-escaped header (colour output): strip escapes before searching.+      testCase "handles ANSI-escaped headers" do+        extractTitle+            [ "\ESC[;1msrc/Types.hs:11:1: \ESC[35mwarning:\ESC[0m \ESC[35m[-Wunused-imports]\ESC[0m"+            , "    The import of 'Data.Data' is redundant"+            ]+            @?= "The import of 'Data.Data' is redundant"+    ]+++testParseReload :: [TestTree]+testParseReload =+    [ testGroup "clean build" testCleanBuild+    , testGroup "with errors and warnings" testErrors+    , testGroup "with -fhide-source-paths (no Loading items)" testHideSourcePaths+    , testGroup "with <no location info> errors" testNoLocationInfo+    , testGroup "with Loaded GHCi configuration" testLoadedConfig+    ]+++testCleanBuild :: [TestTree]+testCleanBuild =+    [ testCase "produces GLoading items for each compiled module" do+        let input =+                [ "[1 of 3] Compiling Tricorder.Build ( src/Tricorder.Build.hs, interpreted )"+                , "[2 of 3] Compiling Tricorder.Session    ( src/Tricorder/Session.hs, interpreted )"+                , "[3 of 3] Compiling Main                 ( app/Main.hs, interpreted )"+                , "Ok, 3 modules loaded."+                ]+        parseReload input+            @?= [ Load.Loading+                    Loading+                        { index = 1+                        , total = 3+                        , moduleName = "Tricorder.Build"+                        , sourceFile = "src/Tricorder.Build.hs"+                        }+                , Load.Loading+                    Loading+                        { index = 2+                        , total = 3+                        , moduleName = "Tricorder.Session"+                        , sourceFile = "src/Tricorder/Session.hs"+                        }+                , Load.Loading Loading {index = 3, total = 3, moduleName = "Main", sourceFile = "app/Main.hs"}+                , Load.Summary LoadOutcome.LoadSucceeded+                ]+    , testCase "handles padded module index (e.g. [ 1 of 47])" do+        let input =+                [ "[ 1 of 47] Compiling Main              ( app/Main.hs, interpreted )"+                , "Ok, 1 module loaded."+                ]+        parseReload input+            @?= [ Load.Loading Loading {index = 1, total = 47, moduleName = "Main", sourceFile = "app/Main.hs"}+                , Load.Summary LoadOutcome.LoadSucceeded+                ]+    , testGroup+        "when only summary line"+        [ testCase "returns the summary outcome" do+            parseReload ["Ok, 0 modules loaded."] @?= [Load.Summary LoadOutcome.LoadSucceeded]+        ]+    ]+++testErrors :: [TestTree]+testErrors =+    [ testCase "parses a single-line error" do+        let input = ["src/Foo.hs:10:5: error: Variable not in scope: foo"]+        parseReload input+            @?= [ Load.Message+                    Message+                        { severity = Severity.Error+                        , file = "src/Foo.hs"+                        , startPos = Position 10 5+                        , endPos = Position 10 5+                        , messageLines = ["src/Foo.hs:10:5: error: Variable not in scope: foo"]+                        }+                ]+    , testCase "parses a warning with continuation lines" do+        let input =+                [ "src/Bar.hs:20:3: warning: [-Wunused-imports]"+                , "    Redundant import: Data.List"+                , "    Perhaps you want to remove it."+                ]+        parseReload input+            @?= [ Load.Message+                    Message+                        { severity = Severity.Warning+                        , file = "src/Bar.hs"+                        , startPos = Position 20 3+                        , endPos = Position 20 3+                        , messageLines =+                            [ "src/Bar.hs:20:3: warning: [-Wunused-imports]"+                            , "    Redundant import: Data.List"+                            , "    Perhaps you want to remove it."+                            ]+                        }+                ]+    , testCase "parses a span position (L:C-C2:)" do+        let input = ["src/Baz.hs:5:1-10: error: Parse error"]+        parseReload input+            @?= [ Load.Message+                    Message+                        { severity = Severity.Error+                        , file = "src/Baz.hs"+                        , startPos = Position 5 1+                        , endPos = Position 5 10+                        , messageLines = ["src/Baz.hs:5:1-10: error: Parse error"]+                        }+                ]+    , testCase "parses a span position ((L1,C1)-(L2,C2):)" do+        let input = ["src/Qux.hs:(3,1)-(5,20): error: Multi-line error"]+        parseReload input+            @?= [ Load.Message+                    Message+                        { severity = Severity.Error+                        , file = "src/Qux.hs"+                        , startPos = Position 3 1+                        , endPos = Position 5 20+                        , messageLines = ["src/Qux.hs:(3,1)-(5,20): error: Multi-line error"]+                        }+                ]+    , testCase "parses a span position with double-paren end ((L1,C1)-((L2,C2):)" do+        let input = ["src/Qux.hs:(3,1)-((5,20): error: Multi-line error"]+        parseReload input+            @?= [ Load.Message+                    Message+                        { severity = Severity.Error+                        , file = "src/Qux.hs"+                        , startPos = Position 3 1+                        , endPos = Position 5 20+                        , messageLines = ["src/Qux.hs:(3,1)-((5,20): error: Multi-line error"]+                        }+                ]+    , testCase "parses source-display continuation lines (pipe format)" do+        let input =+                [ "src/Foo.hs:10:5: error: Variable not in scope: foo"+                , "   |"+                , "10 | foo bar"+                , "   | ^^^"+                , "    Suggested fix: import Foo"+                ]+        parseReload input+            @?= [ Load.Message+                    Message+                        { severity = Severity.Error+                        , file = "src/Foo.hs"+                        , startPos = Position 10 5+                        , endPos = Position 10 5+                        , messageLines =+                            [ "src/Foo.hs:10:5: error: Variable not in scope: foo"+                            , "   |"+                            , "10 | foo bar"+                            , "   | ^^^"+                            , "    Suggested fix: import Foo"+                            ]+                        }+                ]+    , testCase "strips ANSI codes from header for matching but stores original in glMessage" do+        let ansiHeader = "\ESC[1msrc/Foo.hs:10:5:\ESC[0m \ESC[91merror:\ESC[0m Variable not in scope: foo"+        parseReload [ansiHeader]+            @?= [ Load.Message+                    Message+                        { severity = Severity.Error+                        , file = "src/Foo.hs"+                        , startPos = Position 10 5+                        , endPos = Position 10 5+                        , messageLines = [ansiHeader]+                        }+                ]+    , testCase "parses a Windows drive-letter path in a diagnostic" do+        let input = ["C:\\path\\file.hs:10:5: error: Variable not in scope: foo"]+        parseReload input+            @?= [ Load.Message+                    Message+                        { severity = Severity.Error+                        , file = "C:\\path\\file.hs"+                        , startPos = Position 10 5+                        , endPos = Position 10 5+                        , messageLines = ["C:\\path\\file.hs:10:5: error: Variable not in scope: foo"]+                        }+                ]+    , testCase "parses mixed Loading, Message, and summary items" do+        let input =+                [ "[1 of 2] Compiling Lib ( src/Lib.hs, interpreted )"+                , "src/Lib.hs:5:1: error: Oops"+                , "[2 of 2] Compiling Main ( app/Main.hs, interpreted )"+                , "Failed, 1 module loaded."+                ]+        parseReload input+            @?= [ Load.Loading Loading {index = 1, total = 2, moduleName = "Lib", sourceFile = "src/Lib.hs"}+                , Load.Message+                    Message+                        { severity = Severity.Error+                        , file = "src/Lib.hs"+                        , startPos = Position 5 1+                        , endPos = Position 5 1+                        , messageLines = ["src/Lib.hs:5:1: error: Oops"]+                        }+                , Load.Loading Loading {index = 2, total = 2, moduleName = "Main", sourceFile = "app/Main.hs"}+                , Load.Summary LoadOutcome.LoadFailed+                ]+    ]+++testHideSourcePaths :: [TestTree]+testHideSourcePaths =+    [ testGroup+        "when source paths are hidden"+        [ testCase "produces no GLoading items" do+            let input =+                    [ "src/Foo.hs:10:5: error: Variable not in scope: foo"+                    , "    Perhaps you meant: 'bar'"+                    , "Failed, one module failed to load."+                    ]+            parseReload input+                @?= [ Load.Message+                        Message+                            { severity = Severity.Error+                            , file = "src/Foo.hs"+                            , startPos = Position 10 5+                            , endPos = Position 10 5+                            , messageLines =+                                [ "src/Foo.hs:10:5: error: Variable not in scope: foo"+                                , "    Perhaps you meant: 'bar'"+                                ]+                            }+                    , Load.Summary LoadOutcome.LoadFailed+                    ]+        ]+    , testGroup+        "when all modules are already up to date"+        [ testCase "returns the summary outcome" do+            -- GHCi with -fhide-source-paths and nothing to recompile+            parseReload ["Ok, 5 modules loaded."] @?= [Load.Summary LoadOutcome.LoadSucceeded]+        ]+    ]+++testNoLocationInfo :: [TestTree]+testNoLocationInfo =+    [ testCase "handles <no location info>: error: with continuation" do+        let input =+                [ "<no location info>: error:"+                , "    Module `Tricorder.Missing' is not loaded."+                ]+        parseReload input+            @?= [ Load.Message+                    Message+                        { severity = Severity.Error+                        , file = "<no location info>"+                        , startPos = Position 0 0+                        , endPos = Position 0 0+                        , messageLines =+                            [ "<no location info>: error:"+                            , "    Module `Tricorder.Missing' is not loaded."+                            ]+                        }+                ]+    , testCase "handles <no location info>: error: with no continuation" do+        parseReload ["<no location info>: error: some error"]+            @?= [ Load.Message+                    Message+                        { severity = Severity.Error+                        , file = "<no location info>"+                        , startPos = Position 0 0+                        , endPos = Position 0 0+                        , messageLines = ["<no location info>: error: some error"]+                        }+                ]+    ]+++testLoadedConfig :: [TestTree]+testLoadedConfig =+    [ testCase "parses a GHCi configuration line" do+        parseReload ["Loaded GHCi configuration from /home/user/project/.ghci"]+            @?= [Load.LoadConfig "/home/user/project/.ghci"]+    , testCase "parses a Windows-style GHCi configuration path" do+        parseReload ["Loaded GHCi configuration from C:\\Users\\user\\project\\.ghci"]+            @?= [Load.LoadConfig "C:\\Users\\user\\project\\.ghci"]+    , testCase "handles config line mixed with other output" do+        let input =+                [ "Loaded GHCi configuration from .ghci"+                , "[1 of 1] Compiling Main ( app/Main.hs, interpreted )"+                , "Ok, 1 module loaded."+                ]+        parseReload input+            @?= [ Load.LoadConfig ".ghci"+                , Load.Loading Loading {index = 1, total = 1, moduleName = "Main", sourceFile = "app/Main.hs"}+                , Load.Summary LoadOutcome.LoadSucceeded+                ]+    ]+++testCollectResultCustom :: [TestTree]+testCollectResultCustom =+    [ testGroup "<no location info> plugin load failure" testPluginLoadFailure+    ]+++-- | Regression test for the \"All good\" bug with home-unit GHC plugins.+--+-- Under @cabal repl --enable-multi-repl@ every unit is interpreted, so a+-- package used as a GHC plugin in the same project is not available as a+-- compiled plugin. The unit that depends on it fails to load, GHCi reports the+-- failure with @\<no location info\>@ (it has no source span), and the load+-- ends with @Failed, N modules loaded@. This output must still surface as an+-- error diagnostic — otherwise the build is silently reported as clean.+testPluginLoadFailure :: [TestTree]+testPluginLoadFailure =+    -- Shape of the GHCi output from `cabal repl --enable-multi-repl` when an+    -- executable loads a home-unit GHC plugin: the plugin package's modules+    -- compile, then the unit using the plugin fails with a location-less error.+    [ testCase "surfaces the plugin load failure as an error diagnostic" do+        Build.Diagnostic.SError `elem` map (.severity) result.diagnostics @? "expected an error diagnostic"+    , testCase "carries the plugin error message in the diagnostic title" do+        "Could not load module \8216My.Plugin\8217." `elem` map (.title) result.diagnostics+            @? "expected the plugin error message in a diagnostic title"+    ]+  where+    reloadOutput =+        [ "[3 of 5] Compiling My.Plugin       ( src/My/Plugin.hs, interpreted )[plugin-pkg-1.0.0-inplace]"+        , "<no location info>: error:"+        , "    Could not load module \8216My.Plugin\8217."+        , "It is a member of the hidden package \8216plugin-pkg-1.0.0\8217."+        , "Perhaps you need to add \8216plugin-pkg\8217 to the build-depends in your .cabal file."+        , "Use -v to see a list of the files searched for."+        , ""+        , "[5 of 5] Compiling Main            ( test/Tests.hs, interpreted )[app-pkg-0.1.0.0-inplace-test]"+        , "Failed, 4 modules loaded."+        ]+    result = collectResultCustom "/project" (parseReload reloadOutput) [] []+++testCollectResult :: [TestTree]+testCollectResult =+    [ testGroup "failed load with no located error" testUnattributedFailure+    ]+++-- 'collectResult' is the safety net: GHCi can end a load with @Failed, …@+-- without emitting any error that carries a source span. The build must never+-- read as clean in that case, so a synthetic error diagnostic is added.+testUnattributedFailure :: [TestTree]+testUnattributedFailure =+    [ testGroup+        "when the load failed but no error was located"+        [ testCase "adds a synthetic error" do+            let reloadOutput =+                    [ "[1 of 2] Compiling Lib  ( src/Lib.hs, interpreted )"+                    , "[2 of 2] Compiling Main ( app/Main.hs, interpreted )"+                    , "Failed, 1 module loaded."+                    ]+                result = collectResult "/project" reloadOutput [] []+            map (.severity) result.diagnostics @?= [SError]+        ]+    , testCase "does not duplicate a failure that already produced a located error" do+        let reloadOutput =+                [ "[1 of 1] Compiling Lib ( src/Lib.hs, interpreted )"+                , "src/Lib.hs:5:1: error: Oops"+                , "Failed, 0 modules loaded."+                ]+            result = collectResult "/project" reloadOutput [] []+        -- Only the real, located diagnostic — no synthetic one appended.+        map (.file) result.diagnostics @?= ["src/Lib.hs"]+    , testCase "adds nothing for a successful load" do+        let reloadOutput =+                [ "[1 of 1] Compiling Main ( app/Main.hs, interpreted )"+                , "Ok, 1 module loaded."+                ]+            result = collectResult "/project" reloadOutput [] []+        result.diagnostics @?= []+    , testGroup+        "when 'Failed,' appears off the summary line"+        [ testCase "does not flag a clean build" do+            -- The load outcome lives on GHCi's single summary line+            -- ("Ok, …" / "Failed, …"). Output printed *during* the load — e.g. a+            -- Template Haskell splice or top-level IO run while interpreting — can+            -- contain a line that happens to begin with "Failed,". That must not be+            -- mistaken for a failed load: the summary here is "Ok," so no synthetic+            -- error belongs.+            let reloadOutput =+                    [ "[1 of 1] Compiling Main ( app/Main.hs, interpreted )"+                    , "Failed, retrying with fallback" -- printed by a TH splice+                    , "Ok, 1 module loaded."+                    ]+                result = collectResult "/project" reloadOutput [] []+            result.diagnostics @?= []+        ]+    ]
+ test/Unit/Tricorder/Daemon/Ghci/SessionSpec.hs view
@@ -0,0 +1,152 @@+module Unit.Tricorder.Daemon.Ghci.SessionSpec (test_Session) where++import Atelier.Effects.PubSub.Pub (Pub)+import Control.Exception (ErrorCall (..))+import Effectful (IOE, runEff)+import Effectful.Exception (try)+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertBool, testCase, (@?=))++import Atelier.Effects.PubSub.Pub qualified as Pub+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set++import Tricorder.Build (BuildProgress, Diagnostic (..), Severity (..))+import Tricorder.Daemon.Ghci.Controls (Controls (..))+import Tricorder.Daemon.Ghci.LoadResult (LoadResult (..))+import Tricorder.Daemon.Ghci.Session (Session)+import Tricorder.Runtime (ProjectRoot (..))+import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))+import Tricorder.Session.Stage (Stage (..))++import Tricorder.Daemon.Ghci.Session qualified as Session+++test_Session :: TestTree+test_Session =+    testGroup+        "Session"+        [ testGroup "runScripted" testScripted+        ]+++--------------------------------------------------------------------------------+-- Scripted interpreter tests+--------------------------------------------------------------------------------++testScripted :: [TestTree]+testScripted =+    [ testGroup+        "withGhci"+        [ testGroup+            "initial load"+            [ testCase "returns scripted messages" do+                LoadResult {diagnostics = msgs} <-+                    runScripted [simpleResult [errMsg]]+                        $ Session.with Pub.publish cmd (ProjectRoot "/") \initial _ -> pure initial+                msgs @?= [errMsg]+            , testCase "returns empty list when scripted result has no messages" do+                LoadResult {diagnostics = msgs} <-+                    runScripted [simpleResult []]+                        $ Session.with Pub.publish cmd (ProjectRoot "/") \initial _ -> pure initial+                msgs @?= []+            , testCase "throws when scripted result is Left" do+                result <-+                    runScripted [Left (toException boom)]+                        $ try @ErrorCall+                        $ Session.with Pub.publish cmd (ProjectRoot "/") \initial _ -> pure initial+                result @?= Left boom+            ]+        , testGroup+            "reloading"+            [ testCase "returns scripted messages" do+                LoadResult {diagnostics = msgs} <-+                    runScripted [simpleResult [warnMsg], simpleResult [errMsg]]+                        $ Session.with Pub.publish cmd (ProjectRoot "/") \_ controls -> controls.reload+                msgs @?= [errMsg]+            , testCase "throws when scripted result is Left" do+                result <-+                    runScripted [Left (toException boom)]+                        $ try @ErrorCall+                        $ Session.with Pub.publish cmd (ProjectRoot "/") \_ controls -> controls.reload+                result @?= Left boom+            ]+        ]+    , testGroup+        "sequencing"+        [ testCase "consumes results in order across mixed operations" do+            (a, b) <- runScripted [simpleResult [errMsg], simpleResult [warnMsg]] do+                Session.with Pub.publish cmd (ProjectRoot "/") \LoadResult {diagnostics = a} controls -> do+                    LoadResult {diagnostics = b} <- controls.reload+                    pure (a, b)+            a @?= [errMsg]+            b @?= [warnMsg]+        , testCase "recover scenario: error then success" do+            result <- runScripted [Left (toException boom), simpleResult []] do+                r1 <- try @ErrorCall $ Session.with Pub.publish cmd (ProjectRoot "/") \i _ -> pure i+                LoadResult {diagnostics = r2} <- Session.with Pub.publish cmd (ProjectRoot "/") \i _ -> pure i+                pure (r1, r2)+            assertBool "expected Left" $ isLeft (fst result)+            snd result @?= []+        ]+    ]+++--------------------------------------------------------------------------------+-- Helpers+--------------------------------------------------------------------------------++cmd :: RenderedCommand 'Build+cmd = RenderedCommand "cabal repl lib:foo"+++boom :: ErrorCall+boom = ErrorCall "simulated GHCi crash"+++errMsg :: Diagnostic+errMsg =+    Diagnostic+        { severity = SError+        , file = "src/Foo.hs"+        , line = 1+        , col = 1+        , endLine = 1+        , endCol = 5+        , title = "Variable not in scope: foo"+        , text = "Variable not in scope: foo"+        }+++warnMsg :: Diagnostic+warnMsg =+    Diagnostic+        { severity = SWarning+        , file = "src/Bar.hs"+        , line = 10+        , col = 3+        , endLine = 10+        , endCol = 8+        , title = "Unused import"+        , text = "Unused import"+        }+++-- | Convenience constructor: a scripted result with no compiled-file info.+simpleResult :: [Diagnostic] -> Either SomeException LoadResult+simpleResult msgs =+    Right+        LoadResult+            { moduleCount = 0+            , compiledFiles = Set.empty+            , loadedModules = Map.empty+            , targetNames = []+            , diagnostics = msgs+            }+++runScripted+    :: [Either SomeException LoadResult]+    -> Eff '[Session, Pub BuildProgress, IOE] a+    -> IO a+runScripted results = runEff . Pub.runNoOp . Session.runScripted results
− test/Unit/Tricorder/Daemon/GhciSession/GhciParserSpec.hs
@@ -1,503 +0,0 @@-module Unit.Tricorder.Daemon.GhciSession.GhciParserSpec (test_GhciParser) where--import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (assertBool, testCase, (@?=))--import Tricorder.Build (Diagnostic (..), Severity (..))-import Tricorder.Daemon.GhciSession.GhciParser-    ( GhciLoad (..)-    , GhciLoading (..)-    , GhciMessage (..)-    , GhciSeverity (..)-    , LoadOutcome (..)-    , LoadResult (..)-    , Position (..)-    , collectResult-    , collectResultCustom-    , parseReload-    , parseShowModules-    , parseShowTargets-    )---test_GhciParser :: TestTree-test_GhciParser =-    testGroup-        "GhciParser"-        [ testGroup-            "parseReload"-            [ testGroup "clean build" testCleanBuild-            , testGroup "with errors and warnings" testErrors-            , testGroup "with -fhide-source-paths (no Loading items)" testHideSourcePaths-            , testGroup "with <no location info> errors" testNoLocationInfo-            , testGroup "with Loaded GHCi configuration" testLoadedConfig-            ]-        , testGroup-            "parseShowModules"-            [ testGroup "typical output" testShowModules-            , testGroup "empty / blank input" testShowModulesEmpty-            ]-        , testGroup "parseShowTargets" testShowTargets-        , testGroup-            "collectResultCustom"-            [ testGroup "<no location info> plugin load failure" testPluginLoadFailure-            ]-        , testGroup-            "collectResult"-            [ testGroup "failed load with no located error" testUnattributedFailure-            ]-        ]-------------------------------------------------------------------------------------- parseReload: clean build-----------------------------------------------------------------------------------testCleanBuild :: [TestTree]-testCleanBuild =-    [ testCase "produces GLoading items for each compiled module" do-        let input =-                [ "[1 of 3] Compiling Tricorder.Build ( src/Tricorder.Build.hs, interpreted )"-                , "[2 of 3] Compiling Tricorder.Session    ( src/Tricorder/Session.hs, interpreted )"-                , "[3 of 3] Compiling Main                 ( app/Main.hs, interpreted )"-                , "Ok, 3 modules loaded."-                ]-        parseReload input-            @?= [ GLoading-                    GhciLoading-                        { index = 1-                        , total = 3-                        , moduleName = "Tricorder.Build"-                        , sourceFile = "src/Tricorder.Build.hs"-                        }-                , GLoading-                    GhciLoading-                        { index = 2-                        , total = 3-                        , moduleName = "Tricorder.Session"-                        , sourceFile = "src/Tricorder/Session.hs"-                        }-                , GLoading GhciLoading {index = 3, total = 3, moduleName = "Main", sourceFile = "app/Main.hs"}-                , GSummary LoadSucceeded-                ]-    , testCase "handles padded module index (e.g. [ 1 of 47])" do-        let input =-                [ "[ 1 of 47] Compiling Main              ( app/Main.hs, interpreted )"-                , "Ok, 1 module loaded."-                ]-        parseReload input-            @?= [ GLoading GhciLoading {index = 1, total = 47, moduleName = "Main", sourceFile = "app/Main.hs"}-                , GSummary LoadSucceeded-                ]-    , testGroup-        "when only summary line"-        [ testCase "returns the summary outcome" do-            parseReload ["Ok, 0 modules loaded."] @?= [GSummary LoadSucceeded]-        ]-    ]-------------------------------------------------------------------------------------- parseReload: errors and warnings-----------------------------------------------------------------------------------testErrors :: [TestTree]-testErrors =-    [ testCase "parses a single-line error" do-        let input = ["src/Foo.hs:10:5: error: Variable not in scope: foo"]-        parseReload input-            @?= [ GMessage-                    GhciMessage-                        { severity = GError-                        , file = "src/Foo.hs"-                        , startPos = Position 10 5-                        , endPos = Position 10 5-                        , messageLines = ["src/Foo.hs:10:5: error: Variable not in scope: foo"]-                        }-                ]-    , testCase "parses a warning with continuation lines" do-        let input =-                [ "src/Bar.hs:20:3: warning: [-Wunused-imports]"-                , "    Redundant import: Data.List"-                , "    Perhaps you want to remove it."-                ]-        parseReload input-            @?= [ GMessage-                    GhciMessage-                        { severity = GWarning-                        , file = "src/Bar.hs"-                        , startPos = Position 20 3-                        , endPos = Position 20 3-                        , messageLines =-                            [ "src/Bar.hs:20:3: warning: [-Wunused-imports]"-                            , "    Redundant import: Data.List"-                            , "    Perhaps you want to remove it."-                            ]-                        }-                ]-    , testCase "parses a span position (L:C-C2:)" do-        let input = ["src/Baz.hs:5:1-10: error: Parse error"]-        parseReload input-            @?= [ GMessage-                    GhciMessage-                        { severity = GError-                        , file = "src/Baz.hs"-                        , startPos = Position 5 1-                        , endPos = Position 5 10-                        , messageLines = ["src/Baz.hs:5:1-10: error: Parse error"]-                        }-                ]-    , testCase "parses a span position ((L1,C1)-(L2,C2):)" do-        let input = ["src/Qux.hs:(3,1)-(5,20): error: Multi-line error"]-        parseReload input-            @?= [ GMessage-                    GhciMessage-                        { severity = GError-                        , file = "src/Qux.hs"-                        , startPos = Position 3 1-                        , endPos = Position 5 20-                        , messageLines = ["src/Qux.hs:(3,1)-(5,20): error: Multi-line error"]-                        }-                ]-    , testCase "parses a span position with double-paren end ((L1,C1)-((L2,C2):)" do-        let input = ["src/Qux.hs:(3,1)-((5,20): error: Multi-line error"]-        parseReload input-            @?= [ GMessage-                    GhciMessage-                        { severity = GError-                        , file = "src/Qux.hs"-                        , startPos = Position 3 1-                        , endPos = Position 5 20-                        , messageLines = ["src/Qux.hs:(3,1)-((5,20): error: Multi-line error"]-                        }-                ]-    , testCase "parses source-display continuation lines (pipe format)" do-        let input =-                [ "src/Foo.hs:10:5: error: Variable not in scope: foo"-                , "   |"-                , "10 | foo bar"-                , "   | ^^^"-                , "    Suggested fix: import Foo"-                ]-        parseReload input-            @?= [ GMessage-                    GhciMessage-                        { severity = GError-                        , file = "src/Foo.hs"-                        , startPos = Position 10 5-                        , endPos = Position 10 5-                        , messageLines =-                            [ "src/Foo.hs:10:5: error: Variable not in scope: foo"-                            , "   |"-                            , "10 | foo bar"-                            , "   | ^^^"-                            , "    Suggested fix: import Foo"-                            ]-                        }-                ]-    , testCase "strips ANSI codes from header for matching but stores original in glMessage" do-        let ansiHeader = "\ESC[1msrc/Foo.hs:10:5:\ESC[0m \ESC[91merror:\ESC[0m Variable not in scope: foo"-        parseReload [ansiHeader]-            @?= [ GMessage-                    GhciMessage-                        { severity = GError-                        , file = "src/Foo.hs"-                        , startPos = Position 10 5-                        , endPos = Position 10 5-                        , messageLines = [ansiHeader]-                        }-                ]-    , testCase "parses a Windows drive-letter path in a diagnostic" do-        let input = ["C:\\path\\file.hs:10:5: error: Variable not in scope: foo"]-        parseReload input-            @?= [ GMessage-                    GhciMessage-                        { severity = GError-                        , file = "C:\\path\\file.hs"-                        , startPos = Position 10 5-                        , endPos = Position 10 5-                        , messageLines = ["C:\\path\\file.hs:10:5: error: Variable not in scope: foo"]-                        }-                ]-    , testCase "parses mixed Loading, Message, and summary items" do-        let input =-                [ "[1 of 2] Compiling Lib ( src/Lib.hs, interpreted )"-                , "src/Lib.hs:5:1: error: Oops"-                , "[2 of 2] Compiling Main ( app/Main.hs, interpreted )"-                , "Failed, 1 module loaded."-                ]-        parseReload input-            @?= [ GLoading GhciLoading {index = 1, total = 2, moduleName = "Lib", sourceFile = "src/Lib.hs"}-                , GMessage-                    GhciMessage-                        { severity = GError-                        , file = "src/Lib.hs"-                        , startPos = Position 5 1-                        , endPos = Position 5 1-                        , messageLines = ["src/Lib.hs:5:1: error: Oops"]-                        }-                , GLoading GhciLoading {index = 2, total = 2, moduleName = "Main", sourceFile = "app/Main.hs"}-                , GSummary LoadFailed-                ]-    ]-------------------------------------------------------------------------------------- parseReload: -fhide-source-paths output-----------------------------------------------------------------------------------testHideSourcePaths :: [TestTree]-testHideSourcePaths =-    [ testGroup-        "when source paths are hidden"-        [ testCase "produces no GLoading items" do-            let input =-                    [ "src/Foo.hs:10:5: error: Variable not in scope: foo"-                    , "    Perhaps you meant: 'bar'"-                    , "Failed, one module failed to load."-                    ]-            parseReload input-                @?= [ GMessage-                        GhciMessage-                            { severity = GError-                            , file = "src/Foo.hs"-                            , startPos = Position 10 5-                            , endPos = Position 10 5-                            , messageLines =-                                [ "src/Foo.hs:10:5: error: Variable not in scope: foo"-                                , "    Perhaps you meant: 'bar'"-                                ]-                            }-                    , GSummary LoadFailed-                    ]-        ]-    , testGroup-        "when all modules are already up to date"-        [ testCase "returns the summary outcome" do-            -- GHCi with -fhide-source-paths and nothing to recompile-            parseReload ["Ok, 5 modules loaded."] @?= [GSummary LoadSucceeded]-        ]-    ]-------------------------------------------------------------------------------------- parseReload: <no location info> errors-----------------------------------------------------------------------------------testNoLocationInfo :: [TestTree]-testNoLocationInfo =-    [ testCase "handles <no location info>: error: with continuation" do-        let input =-                [ "<no location info>: error:"-                , "    Module `Tricorder.Missing' is not loaded."-                ]-        parseReload input-            @?= [ GMessage-                    GhciMessage-                        { severity = GError-                        , file = "<no location info>"-                        , startPos = Position 0 0-                        , endPos = Position 0 0-                        , messageLines =-                            [ "<no location info>: error:"-                            , "    Module `Tricorder.Missing' is not loaded."-                            ]-                        }-                ]-    , testCase "handles <no location info>: error: with no continuation" do-        parseReload ["<no location info>: error: some error"]-            @?= [ GMessage-                    GhciMessage-                        { severity = GError-                        , file = "<no location info>"-                        , startPos = Position 0 0-                        , endPos = Position 0 0-                        , messageLines = ["<no location info>: error: some error"]-                        }-                ]-    ]-------------------------------------------------------------------------------------- parseReload: Loaded GHCi configuration-----------------------------------------------------------------------------------testLoadedConfig :: [TestTree]-testLoadedConfig =-    [ testCase "parses a GHCi configuration line" do-        parseReload ["Loaded GHCi configuration from /home/user/project/.ghci"]-            @?= [GLoadConfig "/home/user/project/.ghci"]-    , testCase "parses a Windows-style GHCi configuration path" do-        parseReload ["Loaded GHCi configuration from C:\\Users\\user\\project\\.ghci"]-            @?= [GLoadConfig "C:\\Users\\user\\project\\.ghci"]-    , testCase "handles config line mixed with other output" do-        let input =-                [ "Loaded GHCi configuration from .ghci"-                , "[1 of 1] Compiling Main ( app/Main.hs, interpreted )"-                , "Ok, 1 module loaded."-                ]-        parseReload input-            @?= [ GLoadConfig ".ghci"-                , GLoading GhciLoading {index = 1, total = 1, moduleName = "Main", sourceFile = "app/Main.hs"}-                , GSummary LoadSucceeded-                ]-    ]-------------------------------------------------------------------------------------- parseShowModules-----------------------------------------------------------------------------------testShowModules :: [TestTree]-testShowModules =-    [ testCase "parses typical :show modules output" do-        let input =-                [ "Tricorder.Build     ( src/Tricorder.Build.hs, interpreted )"-                , "Tricorder.Session        ( src/Tricorder/Session.hs, interpreted )"-                , "Main                     ( app/Main.hs, interpreted )"-                ]-        parseShowModules input-            @?= [ ("Tricorder.Build", "src/Tricorder.Build.hs")-                , ("Tricorder.Session", "src/Tricorder/Session.hs")-                , ("Main", "app/Main.hs")-                ]-    , testCase "parses absolute paths" do-        parseShowModules ["Lib ( /home/user/project/src/Lib.hs, interpreted )"]-            @?= [("Lib", "/home/user/project/src/Lib.hs")]-    , testCase "strips ANSI codes before parsing" do-        parseShowModules ["\ESC[1mMain\ESC[0m                     ( app/Main.hs, interpreted )"]-            @?= [("Main", "app/Main.hs")]-    ]---testShowModulesEmpty :: [TestTree]-testShowModulesEmpty =-    [ testCase "returns empty list for empty input" do-        parseShowModules [] @?= []-    , testCase "returns empty list for blank lines" do-        parseShowModules ["", "   ", "\t"] @?= []-    , testCase "skips lines without '( '" do-        parseShowModules ["just some random text"] @?= []-    ]-------------------------------------------------------------------------------------- parseShowTargets-----------------------------------------------------------------------------------testShowTargets :: [TestTree]-testShowTargets =-    [ testCase "parses module names emitted by cabal repl --enable-multi-repl" do-        parseShowTargets-            [ "Atelier.Effects.Cache"-            , "Atelier.Effects.Chan"-            , "Paths_tricorder"-            ]-            @?= ["Atelier.Effects.Cache", "Atelier.Effects.Chan", "Paths_tricorder"]-    , testCase "parses file-path targets emitted by plain ghci" do-        parseShowTargets ["src/Foo.hs", "test/Bar.hs"]-            @?= ["src/Foo.hs", "test/Bar.hs"]-    , testCase "strips the leading '*' marker for the active interactive target" do-        parseShowTargets ["*Main", "Foo.Bar"] @?= ["Main", "Foo.Bar"]-    , testCase "strips ANSI escape sequences" do-        parseShowTargets ["\ESC[1mFoo.Bar\ESC[0m"] @?= ["Foo.Bar"]-    , testCase "skips blank and whitespace-only lines" do-        parseShowTargets ["", "   ", "\t", "Real.Target"] @?= ["Real.Target"]-    , testCase "returns empty list for empty input" do-        parseShowTargets [] @?= []-    ]-------------------------------------------------------------------------------------- collectResultCustom: <no location info> plugin load failure------------------------------------------------------------------------------------- | Regression test for the \"All good\" bug with home-unit GHC plugins.------ Under @cabal repl --enable-multi-repl@ every unit is interpreted, so a--- package used as a GHC plugin in the same project is not available as a--- compiled plugin. The unit that depends on it fails to load, GHCi reports the--- failure with @\<no location info\>@ (it has no source span), and the load--- ends with @Failed, N modules loaded@. This output must still surface as an--- error diagnostic — otherwise the build is silently reported as clean.-testPluginLoadFailure :: [TestTree]-testPluginLoadFailure =-    -- Shape of the GHCi output from `cabal repl --enable-multi-repl` when an-    -- executable loads a home-unit GHC plugin: the plugin package's modules-    -- compile, then the unit using the plugin fails with a location-less error.-    [ testCase "surfaces the plugin load failure as an error diagnostic" do-        assertBool "expected an error diagnostic"-            $ SError `elem` map (.severity) result.diagnostics-    , testCase "carries the plugin error message in the diagnostic title" do-        assertBool "expected the plugin error message in a diagnostic title"-            $ "Could not load module \8216My.Plugin\8217." `elem` map (.title) result.diagnostics-    ]-  where-    reloadOutput =-        [ "[3 of 5] Compiling My.Plugin       ( src/My/Plugin.hs, interpreted )[plugin-pkg-1.0.0-inplace]"-        , "<no location info>: error:"-        , "    Could not load module \8216My.Plugin\8217."-        , "It is a member of the hidden package \8216plugin-pkg-1.0.0\8217."-        , "Perhaps you need to add \8216plugin-pkg\8217 to the build-depends in your .cabal file."-        , "Use -v to see a list of the files searched for."-        , ""-        , "[5 of 5] Compiling Main            ( test/Tests.hs, interpreted )[app-pkg-0.1.0.0-inplace-test]"-        , "Failed, 4 modules loaded."-        ]-    result = collectResultCustom "/project" (parseReload reloadOutput) [] []-------------------------------------------------------------------------------------- collectResult: failed load with no located error------------------------------------------------------------------------------------- 'collectResult' is the safety net: GHCi can end a load with @Failed, …@--- without emitting any error that carries a source span. The build must never--- read as clean in that case, so a synthetic error diagnostic is added.-testUnattributedFailure :: [TestTree]-testUnattributedFailure =-    [ testGroup-        "when the load failed but no error was located"-        [ testCase "adds a synthetic error" do-            let reloadOutput =-                    [ "[1 of 2] Compiling Lib  ( src/Lib.hs, interpreted )"-                    , "[2 of 2] Compiling Main ( app/Main.hs, interpreted )"-                    , "Failed, 1 module loaded."-                    ]-                result = collectResult "/project" reloadOutput [] []-            map (.severity) result.diagnostics @?= [SError]-        ]-    , testCase "does not duplicate a failure that already produced a located error" do-        let reloadOutput =-                [ "[1 of 1] Compiling Lib ( src/Lib.hs, interpreted )"-                , "src/Lib.hs:5:1: error: Oops"-                , "Failed, 0 modules loaded."-                ]-            result = collectResult "/project" reloadOutput [] []-        -- Only the real, located diagnostic — no synthetic one appended.-        map (.file) result.diagnostics @?= ["src/Lib.hs"]-    , testCase "adds nothing for a successful load" do-        let reloadOutput =-                [ "[1 of 1] Compiling Main ( app/Main.hs, interpreted )"-                , "Ok, 1 module loaded."-                ]-            result = collectResult "/project" reloadOutput [] []-        result.diagnostics @?= []-    , testGroup-        "when 'Failed,' appears off the summary line"-        [ testCase "does not flag a clean build" do-            -- The load outcome lives on GHCi's single summary line-            -- ("Ok, …" / "Failed, …"). Output printed *during* the load — e.g. a-            -- Template Haskell splice or top-level IO run while interpreting — can-            -- contain a line that happens to begin with "Failed,". That must not be-            -- mistaken for a failed load: the summary here is "Ok," so no synthetic-            -- error belongs.-            let reloadOutput =-                    [ "[1 of 1] Compiling Main ( app/Main.hs, interpreted )"-                    , "Failed, retrying with fallback" -- printed by a TH splice-                    , "Ok, 1 module loaded."-                    ]-                result = collectResult "/project" reloadOutput [] []-            result.diagnostics @?= []-        ]-    ]
− test/Unit/Tricorder/Daemon/GhciSession/GhciProcessSpec.hs
@@ -1,569 +0,0 @@-module Unit.Tricorder.Daemon.GhciSession.GhciProcessSpec (test_GhciProcess) where--import Atelier.Effects.Conc (runConc)-import Atelier.Effects.Delay (runDelay)-import Atelier.Effects.File (runFile)-import Atelier.Effects.Log (Message (..), Severity (..), runLogNoOp, runLogWriter)-import Atelier.Effects.Process (runProcessIO, terminateProcessGroup, withProcessGroup)-import Atelier.Effects.Process.Internal (RunningProcess (..))-import Atelier.Effects.Timeout (runTimeout)-import Atelier.Time (Millisecond)-import Control.Concurrent (threadDelay)-import Control.Concurrent.STM (newTVarIO)-import Control.Exception (IOException, catch)-import Data.Char (isDigit)-import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)-import Data.Time.Units (Second)-import Effectful (runEff)-import Effectful.Concurrent (runConcurrent)-import Effectful.Exception (trySync)-import Effectful.Writer.Static.Shared (runWriter)-import System.IO (hGetLine)-import System.Posix.Signals (nullSignal, sigKILL, signalProcess)-import System.Process.Typed-    ( createPipe-    , getStderr-    , getStdin-    , getStdout-    , setCreateGroup-    , setStderr-    , setStdin-    , setStdout-    , shell-    , startProcess-    , stopProcess-    , waitExitCode-    )-import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (assertFailure, testCase, (@?), (@?=))--import Atelier.Effects.Conc qualified as Conc-import Atelier.Effects.Delay qualified as Delay-import Atelier.Effects.File qualified as File-import Atelier.Effects.Process qualified as AProc-import Data.Text qualified as T-import System.Process qualified as Process-import System.Timeout qualified--import Tricorder.Daemon.GhciSession.GhciProcess-    ( GhciProcess (..)-    , GhciProcessError (..)-    , InterruptDecision (..)-    , SessionState (..)-    , UnexpectedExit (..)-    , decideInterrupt-    , drainUntil-    , execGhci-    , waitForBannerOrFail-    )---test_GhciProcess :: TestTree-test_GhciProcess =-    testGroup-        "GhciProcess"-        [ testGroup "decideInterrupt" testDecideInterrupt-        , testGroup "drainUntil" testDrainUntil-        , testGroup "execGhci" testExecGhciScope-        , testGroup "execGhci (stale marker desync)" testExecGhciStaleMarker-        , testGroup "execGhci (sync marker scope independence)" testSyncMarkerScopeIndependent-        , testGroup "waitForBannerOrFail" testWaitForBannerOrFail-        , testGroup "withProcessGroup (process group)" testWithProcessGroupCleanup-        , testGroup "terminateProcessGroup (process group)" testTerminateProcessGroup-        ]----- | Mirrors the private 'markerFor' helper (not exported), using the same--- reconstruction as 'testExecGhciStaleMarker' below.-finishMarker :: Int -> Text-finishMarker n = "#~TRI-FINISH-" <> show n <> "~#"---testDrainUntil :: [TestTree]-testDrainUntil =-    [ testCase "returns accumulated non-marker lines in order and stops at the marker" do-        (r, w) <- Process.createPipe-        (result, _msgs) <--            runEff-                . runWriter @[Message]-                . runLogWriter-                . runFile-                $ do-                    for_ ["line1", "line2", finishMarker 1, "line3"] (File.hPutTextLn w)-                    File.hClose w-                    drainUntil r (finishMarker 1) (\_ -> pure ())-        result @?= ["line1", "line2"]-    , testCase "streams each non-marker line to onLine, in order, before returning" do-        (r, w) <- Process.createPipe-        seenRef <- newIORef []-        (result, _msgs) <--            runEff-                . runWriter @[Message]-                . runLogWriter-                . runFile-                $ do-                    for_ ["a", "b", "c", finishMarker 2] (File.hPutTextLn w)-                    File.hClose w-                    drainUntil r (finishMarker 2) (\l -> liftIO $ modifyIORef' seenRef (l :))-        seen <- reverse <$> readIORef seenRef-        seen @?= ["a", "b", "c"]-        result @?= seen-    , testCase "skips a stale marker with a different suffix and keeps draining" do-        (r, w) <- Process.createPipe-        (result, _msgs) <--            runEff-                . runWriter @[Message]-                . runLogWriter-                . runFile-                $ do-                    -- 'finishMarker 5' is a leftover from a prior, interrupted-                    -- command; the drain waiting for 'finishMarker 9' must-                    -- skip it rather than stopping.-                    for_ ["before", finishMarker 5, "after", finishMarker 9] (File.hPutTextLn w)-                    File.hClose w-                    drainUntil r (finishMarker 9) (\_ -> pure ())-        result @?= ["before", "after"]-    , testCase-        "throws UnexpectedExit with ALL accumulated lines (in order) on EOF, not just the last one"-        do-            (r, w) <- Process.createPipe-            (outcome, _msgs) <--                runEff-                    . runWriter @[Message]-                    . runLogWriter-                    . runFile-                    $ do-                        for_ ["first", "second", "third"] (File.hPutTextLn w)-                        File.hClose w -- EOF before the marker ever arrives-                        trySync (drainUntil r (finishMarker 1) (\_ -> pure ()))-            case outcome of-                Right ls -> assertFailure ("expected UnexpectedExit, got: " <> show ls)-                Left ex -> case fromException ex of-                    Just (UnexpectedExit (MkUnexpectedExit m ls)) -> do-                        m @?= finishMarker 1-                        ls-                            @?= "Reached EOF before reading marker from GHCi.\nWas looking for marker '#~TRI-FINISH-1~#', but no such marker was found.\nAccumulated output from GHCi so far:\nfirst\nsecond\nthird"-                    other -> assertFailure ("expected UnexpectedExit, got: " <> show other)-    , testCase "throws UnexpectedExit with no lines when EOF is reached immediately" do-        (r, w) <- Process.createPipe-        (outcome, _msgs) <--            runEff-                . runWriter @[Message]-                . runLogWriter-                . runFile-                $ do-                    File.hClose w-                    trySync (drainUntil r (finishMarker 1) (\_ -> pure ()))-        case outcome of-            Right ls -> assertFailure ("expected UnexpectedExit, got: " <> show ls)-            Left ex -> case fromException ex of-                Just (UnexpectedExit (MkUnexpectedExit m ls)) -> do-                    m @?= finishMarker 1-                    let expected =-                            "Reached EOF before reading marker from GHCi.\nWas looking for marker '#~TRI-FINISH-1~#', but no such marker was found.\nGHCi returned no output before we reached what we believe is EOF.\nGot the following exception when attempting to read from GHCi:\n<file descriptor:"-                    (expected `T.isPrefixOf` ls)-                        @? ( "error message does not start with expected prefix\nExpected:\n"-                                <> show expected-                                <> "\nActual:\n"-                                <> show ls-                           )-                other -> assertFailure ("expected UnexpectedExit, got: " <> show other)-    , testCase-        "logs an ERROR mentioning the missing marker and the exception when EOF is reached with no output"-        do-            (r, w) <- Process.createPipe-            (_outcome, msgs) <--                runEff-                    . runWriter @[Message]-                    . runLogWriter-                    . runFile-                    $ do-                        File.hClose w-                        trySync (drainUntil r (finishMarker 3) (\_ -> pure ()))-            case filter (\m -> m.severity == ERROR) msgs of-                [] -> assertFailure "expected an ERROR log message"-                (logMsg : _) -> do-                    (finishMarker 3 `T.isInfixOf` logMsg.text) @?= True-                    ("GHCi returned no output" `T.isInfixOf` logMsg.text) @?= True-    , testCase "logs an ERROR including the accumulated output when EOF is reached mid-output" do-        (r, w) <- Process.createPipe-        (_outcome, msgs) <--            runEff-                . runWriter @[Message]-                . runLogWriter-                . runFile-                $ do-                    for_ ["oops-line-1", "oops-line-2"] (File.hPutTextLn w)-                    File.hClose w-                    trySync (drainUntil r (finishMarker 4) (\_ -> pure ()))-        case filter (\m -> m.severity == ERROR) msgs of-            [] -> assertFailure "expected an ERROR log message"-            (logMsg : _) -> do-                (finishMarker 4 `T.isInfixOf` logMsg.text) @?= True-                ("oops-line-1" `T.isInfixOf` logMsg.text) @?= True-                ("oops-line-2" `T.isInfixOf` logMsg.text) @?= True-    ]----- | Regression for the touch-during-reload desync. Interrupting a *Busy* GHCi--- (a reload in flight) leaves a stale sync marker in the stdout/stderr buffers--- ahead of the next command's real output. Because 'drainUntil' used to stop on--- ANY marker-prefix line, the next 'execGhci' matched that stale marker and--- returned *before its command ran* — surfacing as @All good. (0 modules)@ (or,--- on the other timing, a hang). 'execGhci' must skip markers that aren't its--- own and stop only on the marker it is waiting for.-testExecGhciStaleMarker :: [TestTree]-testExecGhciStaleMarker =-    [ testCase "skips a stale leftover marker and returns the command's real output" do-        (stdinR, stdinW) <- Process.createPipe-        (stdoutR, stdoutW) <- Process.createPipe-        (stderrR, stderrW) <- Process.createPipe-        -- A dummy process handle to fill the record; 'execGhci' never uses it.-        p <--            startProcess-                $ setStdin createPipe-                $ setStdout createPipe-                $ setStderr createPipe-                $ shell "true"-        _ <- waitExitCode p-        stateVar <- newTVarIO (Idle 9)-        let gp =-                GhciProcess-                    { stdin = stdinW-                    , stdout = stdoutR-                    , stderr = stderrR-                    , handle = RunningProcess p-                    , stateVar-                    }-            -- Mirrors 'markerFor': "#~TRI-FINISH-<n>~#".-            marker n = "#~TRI-FINISH-" <> show (n :: Int) <> "~#" :: Text-        result <--            runEff-                . runConcurrent-                . runTimeout-                . runDelay-                . runFile-                . runLogNoOp-                . runConc-                $ do-                    -- A stale 'marker 5' (left by a prior interrupted reload)-                    -- precedes the fresh command's real output + its 'marker 9'-                    -- (state is 'Idle 9', so 'execGhci' waits for marker 9).-                    for_ [marker 5, "out-line", marker 9] (File.hPutTextLn stdoutW)-                    for_ [marker 5, "err-line", marker 9] (File.hPutTextLn stderrW)-                    File.hClose stdoutW-                    File.hClose stderrW-                    -- Keep the stdin read-end alive across the write inside-                    -- 'execGhci' (otherwise it is GC-finalised → broken pipe).-                    r <- execGhci gp "reload" (\_ -> pure ())-                    File.hClose stdinR-                    pure r-        _ <- (Right <$> stopProcess p) `catch` \(_ :: SomeException) -> pure (Left ())-        result @?= ["out-line", "err-line"]-    ]----- | Root-cause regression for the "stuck Building…" stall. A SIGINT-interrupted--- ':reload' empties GHCi's interactive scope — it drops the implicit--- @import Prelude@ (verified against ghci 9.10). A sync marker built from bare--- Prelude names then fails to run instead of printing: @putStrLn@ is no longer--- in scope, and neither is the @>>@ operator. The marker never appears, so--- 'drainUntil' blocks until the watchdog fires. The marker must therefore use--- only fully-qualified 'System.IO.hPutStrLn' statements (which survive the--- emptied scope), one per stream, with no bare names or operators.------ We assert on exactly what 'execGhci' writes to GHCi's stdin.-testSyncMarkerScopeIndependent :: [TestTree]-testSyncMarkerScopeIndependent =-    [ testCase "writes the finish marker using only fully-qualified names (no bare putStrLn / >>)" do-        (stdinR, stdinW) <- Process.createPipe-        (stdoutR, stdoutW) <- Process.createPipe-        (stderrR, stderrW) <- Process.createPipe-        p <--            startProcess-                $ setStdin createPipe-                $ setStdout createPipe-                $ setStderr createPipe-                $ shell "true"-        _ <- waitExitCode p-        stateVar <- newTVarIO (Idle 9)-        let gp =-                GhciProcess-                    { stdin = stdinW-                    , stdout = stdoutR-                    , stderr = stderrR-                    , handle = RunningProcess p-                    , stateVar-                    }-            marker = "#~TRI-FINISH-9~#" :: Text-        written <--            runEff-                . runConcurrent-                . runTimeout-                . runDelay-                . runFile-                . runLogNoOp-                . runConc-                $ do-                    -- Pre-seed the marker on both streams so the drain returns-                    -- immediately; we only care about what was written to stdin.-                    File.hPutTextLn stdoutW marker-                    File.hPutTextLn stderrW marker-                    File.hClose stdoutW-                    File.hClose stderrW-                    _ <- execGhci gp ":reload" (\_ -> pure ())-                    File.hClose stdinW-                    let readAll acc =-                            trySync (File.hGetLine stdinR) >>= \case-                                Left (_ :: SomeException) -> pure (reverse acc)-                                Right l -> readAll (l : acc)-                    readAll []-        _ <- (Right <$> stopProcess p) `catch` \(_ :: SomeException) -> pure (Left ())-        let blob = T.intercalate "\n" written-        (" >> " `T.isInfixOf` blob) @?= False-        ("System.IO.hPutStrLn System.IO.stdout" `T.isInfixOf` blob) @?= True-        ("System.IO.hPutStrLn System.IO.stderr" `T.isInfixOf` blob) @?= True-    ]----- | Regression: when the build command exits before printing a GHCi banner,--- 'waitForBannerOrFail' must surface the *complete* stderr output in the--- 'StartupFailed' error. The original implementation snapshotted the captured--- lines as soon as the process exited (it waited on 'waitExitCode'), racing--- the concurrent stderr drain — so a burst of error lines still buffered in--- the pipe was truncated, and the real cabal/build failure was lost.-testWaitForBannerOrFail :: [TestTree]-testWaitForBannerOrFail =-    [ testCase "captures the full stderr output when the command exits before the banner" do-        let lineCount = 200 :: Int-            lastLine = "err line " <> show lineCount-        -- The banner and error streams are pipes we drive ourselves, so the-        -- timing is deterministic rather than a race against the OS pipe buffer.-        (bannerOut, bannerOutW) <- Process.createPipe-        (errR, errW) <- Process.createPipe-        result <--            runEff-                . runConcurrent-                . runTimeout-                . runDelay-                . runFile-                . runConc-                $ do-                    -- No banner will ever arrive: close the write end so the-                    -- wait sees EOF at once and takes the "command exited"-                    -- failure branch.-                    File.hClose bannerOutW-                    -- Producer: pause long enough that a snapshot-at-exit reads-                    -- an empty buffer, THEN stream the whole error log and-                    -- close so the drain sees EOF. A correct implementation-                    -- awaits that drain before reading the captured lines.-                    _ <- Conc.fork do-                        Delay.wait (30 :: Millisecond)-                        for_ [1 .. lineCount] \i ->-                            File.hPutTextLn errW ("err line " <> show i :: Text)-                        File.hClose errW-                    trySync (waitForBannerOrFail (5 :: Second) bannerOut errR)-        case result of-            Right () -> assertFailure "expected waitForBannerOrFail to throw a startup error"-            Left ex -> case fromException ex of-                Just (StartupFailed msg) ->-                    (lastLine `T.isInfixOf` msg) @?= True-                other ->-                    assertFailure ("expected StartupFailed, got: " <> show other)-    ]----- | Regression for orphaned/zombie build subprocesses on restart and shutdown.------ The original leak was on the /graceful/ path: GHCi exits cleanly on @:quit@,--- so its leader is reaped, yet the build subprocesses sharing its group linger.--- 'withProcessGroup' must still sweep the whole group even after the leader has--- gone. We simulate it with a leader that forks a long-lived child sharing its--- group and exits on stdin input (mirroring @:quit@); after 'withProcessGroup'--- returns, the child must be gone.-testWithProcessGroupCleanup :: [TestTree]-testWithProcessGroupCleanup =-    [ testCase "terminates the whole group on exit, even after the leader has exited" do-        childPidRef <- newIORef (Nothing :: Maybe Int)-        let scenario =-                runEff-                    . runConcurrent-                    . runTimeout-                    . runDelay-                    . runFile-                    . runConc-                    . runProcessIO-                    $ withProcessGroup procConfig \p -> do-                        -- First stdout line is the long-lived child's pid.-                        line <- File.hGetLine (AProc.getStdout p)-                        liftIO $ writeIORef childPidRef (parsePid (T.unpack line))-                        -- Let the leader exit and reap it, so the cleanup runs-                        -- with the leader already gone — the path the bug needed.-                        File.hPutTextLn (AProc.getStdin p) ""-                        File.hFlush (AProc.getStdin p)-                        void $ AProc.waitExitCode p-        -- Hard wall-clock bound: a regression must surface as a failed-        -- assertion, never as a hang that stalls the whole suite.-        outcome <- System.Timeout.timeout (8_000_000) scenario-        case outcome of-            Nothing -> assertFailure "test timed out (process did not settle)"-            Just () ->-                readIORef childPidRef >>= \case-                    Nothing -> assertFailure "could not capture the child pid"-                    Just childPid -> do-                        died <- waitForProcessDeath childPid-                        -- Never leak the child if the assertion fails.-                        ignoring (signalProcess sigKILL (fromIntegral childPid))-                        died @?= True-    ]-  where-    procConfig =-        setStdin createPipe-            $ setStdout createPipe-            $ setStderr createPipe-            $ shell "sleep 30 & echo \"$!\"; read _quit"----- | 'terminateProcessGroup' must kill the whole group when called mid-flight--- (the leader still alive) — the explicit early-termination path the test--- runner uses to abort a one-shot @cabal repl test:…@ from another thread.-testTerminateProcessGroup :: [TestTree]-testTerminateProcessGroup =-    [ testCase "kills the whole group, not just the leader, mid-flight" do-        outcome <- System.Timeout.timeout (8_000_000) do-            p <--                startProcess-                    $ setStdin createPipe-                    $ setStdout createPipe-                    $ setStderr createPipe-                    $ setCreateGroup True-                    $ shell "sleep 30 & echo \"$!\"; read _quit"-            childLine <- hGetLine (getStdout p)-            case parsePid childLine of-                Nothing -> do-                    ignoring (stopProcess p)-                    assertFailure ("could not parse child pid from: " <> show childLine)-                Just childPid -> do-                    runEff . runProcessIO $ terminateProcessGroup (RunningProcess p)-                    died <- waitForProcessDeath childPid-                    -- Never leak the child if the assertion fails.-                    ignoring (signalProcess sigKILL (fromIntegral childPid))-                    pure died-        outcome @?= Just True-    ]----- | Parse a pid printed on its own line (tolerating surrounding whitespace).-parsePid :: String -> Maybe Int-parsePid = readMaybe . takeWhile isDigit . dropWhile (not . isDigit)----- | Swallow any exception from a best-effort cleanup action.-ignoring :: IO () -> IO ()-ignoring act = act `catch` \(_ :: SomeException) -> pure ()----- | Poll for up to ~3s for the given pid to disappear from the process table.--- @signalProcess nullSignal@ is a liveness probe: it throws once the process is--- gone (and reaped by init after being orphaned).-waitForProcessDeath :: Int -> IO Bool-waitForProcessDeath pid = go (60 :: Int)-  where-    go 0 = not <$> alive-    go n = do-        a <- alive-        if not a then pure True else threadDelay 50_000 >> go (n - 1)-    alive =-        (signalProcess nullSignal (fromIntegral pid) >> pure True)-            `catch` \(_ :: IOException) -> pure False---testDecideInterrupt :: [TestTree]-testDecideInterrupt =-    -- Regression: an idle GHCi must not be SIGINT'd, since the matching-    -- sync-marker write would leave a stale marker line in stdout/stderr-    -- that the next 'execGhci' drain would match instead of the fresh one,-    -- desyncing the protocol and reporting "0 modules" or hanging.-    [ testCase "is a no-op when the session is Idle" do-        decideInterrupt (Idle 7) @?= (Idle 7, NoOpIdle)-    , testCase "preserves the counter for any Idle state" do-        decideInterrupt (Idle 0) @?= (Idle 0, NoOpIdle)-        decideInterrupt (Idle 42) @?= (Idle 42, NoOpIdle)-    , testCase "advances to Idle (n+1) and emits SendInterruptFor n when Busy" do-        decideInterrupt (Busy 7) @?= (Idle 8, SendInterruptFor 7)-    , testCase "advances correctly from Busy 0" do-        decideInterrupt (Busy 0) @?= (Idle 1, SendInterruptFor 0)-    ]----- | Pins down the 'Conc.scoped' fix in 'execGhci': when the drain forks--- raise 'UnexpectedExit' (because the underlying process exited and EOF'd--- the pipes), the exception must be CONTAINED inside 'execGhci' and--- surfaced via the caller's 'trySync' — not propagated to the ambient--- 'Conc.scoped' that called 'execGhci'.------ Without the inner 'Conc.scoped' in 'execGhci', Ki propagates an--- exception from a forked thread to its owning scope. If 'execGhci' forks--- its drains directly into the ambient scope (the original bug), the--- ambient scope is torn down — siblings die, the whole builder cycle--- unwinds, and the daemon ends up in the "Restarting builder..." state--- the user observed.-testExecGhciScope :: [TestTree]-testExecGhciScope =-    -- Spawn a real subprocess that exits immediately ('true'). Its-    -- stdout/stderr pipes EOF as soon as the child exits, which makes-    -- 'drainUntil' inside 'execGhci' throw 'UnexpectedExit' — exactly the-    -- mid-command termination path the fix exists to handle.-    [ testCase "contains drain exceptions inside its own scope so siblings survive" do-        p <--            startProcess-                $ setStdin createPipe-                $ setStdout createPipe-                $ setStderr createPipe-                $ shell "true"-        -- Wait for the child to actually exit so the pipes are EOF before-        -- 'execGhci' starts draining (otherwise the drain blocks).-        _ <- waitExitCode p-        stateVar <- newTVarIO (Idle 0)-        let gp =-                GhciProcess-                    { stdin = getStdin p-                    , stdout = getStdout p-                    , stderr = getStderr p-                    , handle = RunningProcess p-                    , stateVar-                    }-        siblingDoneRef <- newIORef False-        result <--            runEff-                . runConcurrent-                . runTimeout-                . runDelay-                . runFile-                . runLogNoOp-                . runConc-                $ Conc.scoped do-                    -- A sibling fork in the SAME ambient scope. If the bug-                    -- regresses, the drain exception will tear this scope-                    -- down and the sibling will be cancelled before it can-                    -- flip the ref.-                    sibling <- Conc.fork do-                        Delay.wait (50 :: Millisecond)-                        liftIO (writeIORef siblingDoneRef True)-                    -- Drive 'execGhci' on a dead process; the drains should-                    -- raise 'UnexpectedExit', which 'trySync' must catch-                    -- here rather than letting Ki tear down the scope.-                    execResult <- trySync (execGhci gp "cmd" (\_ -> pure ()))-                    -- Wait for the sibling to run.-                    Conc.await sibling-                    pure execResult-        -- stopProcess flushes the buffered command+marker to a pipe whose-        -- read end is already closed, which raises ResourceVanished. The-        -- subprocess has long since exited; just swallow the cleanup error.-        _ <- (Right <$> stopProcess p) `catch` \(_ :: SomeException) -> pure (Left ())-        siblingDone <- readIORef siblingDoneRef-        siblingDone @?= True-        case result of-            Right _ -> assertFailure "expected execGhci to raise UnexpectedExit"-            Left _ -> pure ()-    ]
− test/Unit/Tricorder/Daemon/GhciSessionSpec.hs
@@ -1,154 +0,0 @@-module Unit.Tricorder.Daemon.GhciSessionSpec (test_GhciSession) where--import Atelier.Effects.Publishing.Pub (Pub)-import Control.Exception (ErrorCall (..))-import Effectful (IOE, runEff)-import Effectful.Exception (try)-import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (assertBool, testCase, (@?=))--import Atelier.Effects.Publishing.Pub qualified as Pub-import Data.Map.Strict qualified as Map-import Data.Set qualified as Set--import Tricorder.Build (BuildProgress, Diagnostic (..), Severity (..))-import Tricorder.Daemon.GhciSession-    ( Controls (..)-    , GhciSession-    , LoadResult (..)-    , runGhciSessionScripted-    , withGhci-    )-import Tricorder.Runtime (ProjectRoot (..))-import Tricorder.Session.Command.RenderedCommand (RenderedCommand (..))-import Tricorder.Session.Stage (Stage (..))---test_GhciSession :: TestTree-test_GhciSession =-    testGroup-        "GhciSession"-        [ testGroup "runGhciSessionScripted" testScripted-        ]-------------------------------------------------------------------------------------- Scripted interpreter tests-----------------------------------------------------------------------------------testScripted :: [TestTree]-testScripted =-    [ testGroup-        "withGhci"-        [ testGroup-            "initial load"-            [ testCase "returns scripted messages" do-                LoadResult {diagnostics = msgs} <--                    runScripted [simpleResult [errMsg]]-                        $ withGhci cmd (ProjectRoot "/") \initial _ -> pure initial-                msgs @?= [errMsg]-            , testCase "returns empty list when scripted result has no messages" do-                LoadResult {diagnostics = msgs} <--                    runScripted [simpleResult []]-                        $ withGhci cmd (ProjectRoot "/") \initial _ -> pure initial-                msgs @?= []-            , testCase "throws when scripted result is Left" do-                result <--                    runScripted [Left (toException boom)]-                        $ try @ErrorCall-                        $ withGhci cmd (ProjectRoot "/") \initial _ -> pure initial-                result @?= Left boom-            ]-        , testGroup-            "reloading"-            [ testCase "returns scripted messages" do-                LoadResult {diagnostics = msgs} <--                    runScripted [simpleResult [warnMsg], simpleResult [errMsg]]-                        $ withGhci cmd (ProjectRoot "/") \_ controls -> controls.reload-                msgs @?= [errMsg]-            , testCase "throws when scripted result is Left" do-                result <--                    runScripted [Left (toException boom)]-                        $ try @ErrorCall-                        $ withGhci cmd (ProjectRoot "/") \_ controls -> controls.reload-                result @?= Left boom-            ]-        ]-    , testGroup-        "sequencing"-        [ testCase "consumes results in order across mixed operations" do-            (a, b) <- runScripted [simpleResult [errMsg], simpleResult [warnMsg]] do-                withGhci cmd (ProjectRoot "/") \LoadResult {diagnostics = a} controls -> do-                    LoadResult {diagnostics = b} <- controls.reload-                    pure (a, b)-            a @?= [errMsg]-            b @?= [warnMsg]-        , testCase "recover scenario: error then success" do-            result <- runScripted [Left (toException boom), simpleResult []] do-                r1 <- try @ErrorCall $ withGhci cmd (ProjectRoot "/") \i _ -> pure i-                LoadResult {diagnostics = r2} <- withGhci cmd (ProjectRoot "/") \i _ -> pure i-                pure (r1, r2)-            assertBool "expected Left" $ isLeft (fst result)-            snd result @?= []-        ]-    ]-------------------------------------------------------------------------------------- Helpers-----------------------------------------------------------------------------------cmd :: RenderedCommand 'Build-cmd = RenderedCommand "cabal repl lib:foo"---boom :: ErrorCall-boom = ErrorCall "simulated GHCi crash"---errMsg :: Diagnostic-errMsg =-    Diagnostic-        { severity = SError-        , file = "src/Foo.hs"-        , line = 1-        , col = 1-        , endLine = 1-        , endCol = 5-        , title = "Variable not in scope: foo"-        , text = "Variable not in scope: foo"-        }---warnMsg :: Diagnostic-warnMsg =-    Diagnostic-        { severity = SWarning-        , file = "src/Bar.hs"-        , line = 10-        , col = 3-        , endLine = 10-        , endCol = 8-        , title = "Unused import"-        , text = "Unused import"-        }----- | Convenience constructor: a scripted result with no compiled-file info.-simpleResult :: [Diagnostic] -> Either SomeException LoadResult-simpleResult msgs =-    Right-        LoadResult-            { moduleCount = 0-            , compiledFiles = Set.empty-            , loadedModules = Map.empty-            , targetNames = []-            , diagnostics = msgs-            }---runScripted-    :: [Either SomeException LoadResult]-    -> Eff '[GhciSession, Pub BuildProgress, IOE] a-    -> IO a-runScripted results = runEff . Pub.runNoOp . runGhciSessionScripted results
test/Unit/Tricorder/Daemon/WatchSpec.hs view
@@ -7,7 +7,7 @@ import Test.Tasty.HUnit (testCase, (@?=)) import Text.Regex.TDFA.ReadRegex (parseRegex) -import Atelier.Effects.Publishing.Pub qualified as Pub+import Atelier.Effects.PubSub.Pub qualified as Pub  import Tricorder.Build.Changes     ( CabalChangeDetected (..)
test/Unit/Tricorder/Session/CabalFileSpec.hs view
@@ -2,7 +2,6 @@  import Atelier.Effects.Env (runEnvConst) import Atelier.Effects.FileSystem (runFileSystemState)-import Atelier.Effects.Input (runInputConst) import Atelier.Effects.Log (runLogNoOp) import Effectful (runPureEff) import Effectful.Reader.Static (runReader)@@ -11,6 +10,7 @@ import Test.Tasty.HUnit (testCase, (@?=))  import Atelier.Effects.FileSystem.Glob qualified as Glob+import Atelier.Effects.Input qualified as Input import Data.Map.Strict qualified as Map  import Tricorder.Runtime (ProjectRoot (..))@@ -183,7 +183,7 @@         runPureEff             . runReader pr             . runLogNoOp-            . runInputConst result+            . Input.withConst result   -- | Pins how a package path resolves to a @.cabal@ file: a directory path
test/Unit/Tricorder/Session/TestTargetSpec.hs view
@@ -23,23 +23,23 @@     [ testCase "infers test: components from targets when testTargets is absent" do         let cfg = def :: Config         TestTarget.resolve cfg (mkTargets ["lib:mylib", "test:mylib-test"])-            @?= TestTarget.parse ["test:mylib-test"]+            @?= mapMaybe TestTarget.parse ["test:mylib-test"]     , testCase "returns empty list when no test: components in targets" do         let cfg = def :: Config         TestTarget.resolve cfg (mkTargets ["lib:mylib", "exe:myapp"])-            @?= TestTarget.parse []+            @?= mapMaybe TestTarget.parse []     , testCase "uses explicit testTargets list when set" do         let cfg = def {testTargets = Just ["test:b-test"]} :: Config         TestTarget.resolve cfg (mkTargets ["lib:a", "test:a-test", "test:b-test"])-            @?= TestTarget.parse ["test:b-test"]+            @?= mapMaybe TestTarget.parse ["test:b-test"]     , testCase "returns empty list when testTargets is explicitly empty" do         let cfg = def {testTargets = Just []} :: Config         TestTarget.resolve cfg (mkTargets ["lib:a", "test:a-test"])-            @?= TestTarget.parse []+            @?= []     , testCase "infers multiple test: components" do         let cfg = def :: Config         TestTarget.resolve cfg (mkTargets ["lib:a", "test:a-test", "test:b-test"])-            @?= TestTarget.parse ["test:a-test", "test:b-test"]+            @?= mapMaybe TestTarget.parse ["test:a-test", "test:b-test"]     ]   where     mkTargets = fmap Target.parse
test/Unit/Tricorder/SessionSpec.hs view
@@ -1,25 +1,30 @@ module Unit.Tricorder.SessionSpec (test_Session) where -import Atelier.Config (LoadedConfig (..)) import Atelier.Effects.FileSystem (runFileSystemState)-import Atelier.Effects.Input (runInputConst) import Atelier.Effects.Log (Message (..), Severity (..), runLogNoOp, runLogWriter)-import Data.Aeson (Value (Null), object, (.=))+import Data.Aeson (object, (.=)) import Distribution.PackageDescription.Parsec (parseGenericPackageDescriptionMaybe) import Effectful (runPureEff) import Effectful.Reader.Static (runReader) import Effectful.State.Static.Shared (evalState) import Effectful.Writer.Static.Shared (execWriter) import Test.Tasty (TestTree, testGroup)-import Test.Tasty.HUnit (testCase, (@?=))+import Test.Tasty.HUnit (assertFailure, testCase, (@?), (@?=)) +import Atelier.Effects.Input qualified as Input+import Data.Aeson qualified as Aeson+import Data.Text qualified as T++import Tricorder.Config.LoadedConfig (LoadedConfig (..)) import Tricorder.Runtime (ProjectRoot (..))-import Tricorder.Session (Session (..), loadSession) import Tricorder.Session.CabalFile (CabalFile (..)) import Tricorder.Session.IdleTimeout (IdleTimeout (..)) import Unit.Tricorder.Session.Helpers (libWithPreludeCabal, preludeOnlyLibCabal) +import Tricorder.Config qualified+import Tricorder.Session qualified as Session + test_Session :: TestTree test_Session =     testGroup@@ -33,12 +38,21 @@ testLoadSession :: TestTree testLoadSession =     testGroup-        "loadSession"+        "load"         [ testGroup             "when every resolved target exposes a custom Prelude module"-            [ testCase "emits a WARN" do+            [ testCase "logs one message" do                 let msgs = captureSessionLogs [preludeOnlyCF]-                any (\m -> m.severity == WARN) msgs @?= True+                length msgs @?= 1+            , testCase "emits a WARN" do+                let msgs = (\m -> (m.severity, m.text)) <$> captureSessionLogs [preludeOnlyCF]+                case msgs of+                    [(sev, msg)] -> do+                        sev @?= WARN+                        "Every resolved target exposes a custom Prelude module" `T.isPrefixOf` msg+                            @? "does not start with expected string"+                    xs ->+                        assertFailure $ "Too many logged messages: " <> show (length xs)             ]         , testGroup             "when not every resolved target exposes a custom Prelude module"@@ -59,16 +73,19 @@         CabalFile "/mixed.cabal"             $ fromMaybe (error "libWithPreludeCabal failed to parse")             $ parseGenericPackageDescriptionMaybe (libWithPreludeCabal "mixed")-    captureSessionLogs cabalFiles =-        runPureEff+    captureSessionLogs = captureSessionLogs' $ Aeson.Object mempty+    captureSessionLogs' cfg cabalFiles =+        filter (\m -> m.severity /= DEBUG)+            . runPureEff             . execWriter @[Message]             . runLogWriter             . evalState @(Map FilePath ByteString) mempty             . runFileSystemState-            . runInputConst cabalFiles+            . Input.withConst cabalFiles             . runReader (ProjectRoot "/")-            . runInputConst (LoadedConfig Null)-            $ loadSession+            . Input.withConst (LoadedConfig $ toStrict $ Aeson.encode cfg)+            . Tricorder.Config.asInput+            $ Session.load   testMissingTargetPlaceholder :: TestTree@@ -101,17 +118,18 @@             ]         ]   where-    sessionCfg session = LoadedConfig $ object ["session" .= object session]+    sessionCfg session = object ["session" .= object session]     captureLogsFor cfg =         runPureEff             . execWriter @[Message]             . runLogWriter             . evalState @(Map FilePath ByteString) mempty             . runFileSystemState-            . runInputConst ([] :: [CabalFile])+            . Input.withConst ([] :: [CabalFile])             . runReader (ProjectRoot "/")-            . runInputConst cfg-            $ loadSession+            . Input.withConst (LoadedConfig $ toStrict $ Aeson.encode cfg)+            . Tricorder.Config.asInput+            $ Session.load   testIdleTimeout :: TestTree@@ -119,10 +137,10 @@     testGroup         "loadSession idleTimeout"         [ testCase "defaults to 300 seconds when unset" do-            (loadSessionWith (LoadedConfig Null)).idleTimeout @?= IdleTimeout 300+            (.idleTimeout) <$> loadSessionWith (Aeson.Object mempty) @?= Right (IdleTimeout 300)         , testCase "reads idle_timeout_seconds from the session config" do-            let cfg = LoadedConfig $ object ["session" .= object ["idle_timeout_seconds" .= (5 :: Int)]]-            (loadSessionWith cfg).idleTimeout @?= IdleTimeout 5+            let cfg = object ["session" .= object ["idle_timeout_seconds" .= (5 :: Int)]]+            (.idleTimeout) <$> (loadSessionWith cfg) @?= Right (IdleTimeout 5)         ]   where     loadSessionWith cfg =@@ -130,7 +148,8 @@             . runLogNoOp             . evalState @(Map FilePath ByteString) mempty             . runFileSystemState-            . runInputConst ([] :: [CabalFile])+            . Input.withConst ([] :: [CabalFile])             . runReader (ProjectRoot "/")-            . runInputConst cfg-            $ loadSession+            . Input.withConst (LoadedConfig $ toStrict $ Aeson.encode cfg)+            . Tricorder.Config.asInput+            $ Session.load
test/Unit/Tricorder/SocketSpec.hs view
@@ -1,24 +1,21 @@ module Unit.Tricorder.SocketSpec (test_Socket) where  import Atelier.Effects.File (File, runFile)+import Atelier.Effects.FileSystem (FileSystem) import Effectful (IOE, runEff)+import Effectful.Exception (trySync) import System.IO (hClose, hGetLine, openFile, writeFile) import Test.Tasty (TestTree, testGroup) import Test.Tasty.HUnit (testCase, (@?=)) +import Atelier.Effects.FileSystem qualified as FileSystem+ import Tricorder.Socket.Client (isDaemonReady)-import Tricorder.Socket.UnixSocket-    ( SocketScript (..)-    , UnixSocket-    , acceptHandle-    , bindSocket-    , removeSocketFile-    , runUnixSocketIO-    , runUnixSocketScripted-    , socketFileExists-    )+import Tricorder.Socket.UnixSocket (SocketScript (..), UnixSocket) +import Tricorder.Socket.UnixSocket qualified as UnixSocket + test_Socket :: TestTree test_Socket =     testGroup@@ -35,29 +32,14 @@ testScripted :: [TestTree] testScripted =     [ testGroup-        "socketFileExists"-        [ testCase "returns True when scripted" do-            result <- runScripted [NextFileCheck True] $ socketFileExists "/"-            result @?= True-        , testCase "returns False when scripted" do-            result <- runScripted [NextFileCheck False] $ socketFileExists "/"-            result @?= False-        ]-    , testGroup-        "removeSocketFile"-        [ testCase "is always a no-op" do-            -- No NextFileCheck/NextAccept needed; just returns ()-            runScripted [] $ removeSocketFile "/nonexistent/path"-        ]-    , testGroup-        "acceptHandle"+        "accept"         [ testCase "returns the scripted handle, readable from a file" do             let tmpPath = "/tmp/tricorder-socket-accept-test.txt"             writeFile tmpPath "hello from test\n"             h <- liftIO $ openFile tmpPath ReadMode             line <- runScripted [NextAccept h] $ do-                sock <- bindSocket "/"-                h' <- acceptHandle sock+                sock <- UnixSocket.bind "/"+                h' <- UnixSocket.accept sock                 liftIO $ hGetLine h'             liftIO $ hClose h             line @?= "hello from test"@@ -79,10 +61,10 @@     , testCase "returns True once a socket is bound and listening" do         let path = "/tmp/tricorder-isdaemonready-bound.sock"         result <- runIO' do-            removeSocketFile path-            _ <- bindSocket path+            void $ trySync $ FileSystem.removeFile path+            _ <- UnixSocket.bind path             isDaemonReady path-        runIO' $ removeSocketFile path+        runIO' $ void $ trySync $ FileSystem.removeFile path         result @?= True     ] @@ -93,9 +75,9 @@  -- | Run scripted socket operations (no Delay needed). runScripted :: [SocketScript] -> Eff '[UnixSocket, File, IOE] a -> IO a-runScripted script = runEff . runFile . runUnixSocketScripted script+runScripted script = runEff . runFile . UnixSocket.runScripted script   -- | Run socket operations against the real IO interpreter.-runIO' :: Eff '[UnixSocket, File, IOE] a -> IO a-runIO' = runEff . runFile . runUnixSocketIO+runIO' :: Eff '[UnixSocket, File, FileSystem, IOE] a -> IO a+runIO' = runEff . FileSystem.runFileSystemIO . runFile . UnixSocket.runIO
+ test/Unit/Tricorder/SourceLookup/ReexportSpec.hs view
@@ -0,0 +1,181 @@+module Unit.Tricorder.SourceLookup.ReexportSpec (test_Reexport, test_exportsByName, test_declaresMember) where++import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (testCase, (@?=))++import Data.Text qualified as T++import Tricorder.SourceLookup.Reexport (declaresMember, exportsByName, reexportCandidates)+++test_Reexport :: TestTree+test_Reexport =+    testGroup+        "Reexport"+        [ testGroup+            "reexportCandidates"+            [ testCase "follows an import list naming the symbol" do+                let source =+                        src+                            [ "module Data.Text"+                            , "    ( Text"+                            , "    , pack -- re-exported"+                            , "    ) where"+                            , ""+                            , "import Data.Text.Array qualified as A"+                            , "import Data.Text.Show (singleton,"+                            , "    unpack)"+                            , "import Data.Text.Internal (Text(..), empty, pack)"+                            ]+                reexportCandidates "pack" source @?= ["Data.Text.Internal"]+            , testCase "ranks imports naming the symbol before unrestricted ones" do+                let source =+                        src+                            [ "module M (foo) where"+                            , "import A"+                            , "import B (foo)"+                            , "import C hiding (foo)"+                            ]+                reexportCandidates "foo" source @?= ["B", "A"]+            , testCase "yields nothing without an export list" do+                let source = src ["module M where", "import A (foo)"]+                reexportCandidates "foo" source @?= []+            , testCase "yields nothing when the export list does not mention the symbol" do+                let source = src ["module M (bar) where", "import A"]+                reexportCandidates "foo" source @?= []+            , testCase "follows a module re-export" do+                let source =+                        src+                            [ "module M (module X, bar) where"+                            , "import qualified Q"+                            , "import Data.Foo as X"+                            , "import Data.Bar"+                            ]+                reexportCandidates "foo" source @?= ["Data.Foo"]+            , testCase "matches a qualified export to its import alias" do+                let source =+                        src+                            [ "module M (T.foo) where"+                            , "import A"+                            , "import qualified B as T"+                            ]+                reexportCandidates "foo" source @?= ["B"]+            , testCase "follows a constructor through a wildcard export" do+                let source =+                        src+                            [ "module M (Shape(..)) where"+                            , "import Shapes (Shape(..))"+                            , "import Other (thing)"+                            ]+                reexportCandidates "Circle" source @?= ["Shapes"]+            , testCase "skips an import that hides the symbol but brings in its parent" do+                let source =+                        src+                            [ "module Prelude (Foldable(foldr)) where"+                            , "import GHC.Base hiding (foldr)"+                            , "import Data.Foldable (Foldable(..))"+                            ]+                reexportCandidates "foldr" source @?= ["Data.Foldable"]+            , testCase "yields nothing for a member of a locally declared parent" do+                let source =+                        src+                            [ "module Data.Foldable (Foldable(..)) where"+                            , "import GHC.Base"+                            , ""+                            , "class Foldable t where"+                            , "    foldr :: (a -> b -> b) -> b -> t a -> b"+                            ]+                reexportCandidates "foldr" source @?= []+            , testCase "matches operators" do+                let source = src ["module M ((<+>)) where", "import Ops ((<+>))"]+                reexportCandidates "<+>" source @?= ["Ops"]+            , testCase "ignores commented-out exports and imports" do+                let source =+                        src+                            [ "{-# LANGUAGE CPP #-}"+                            , "-- | module Fake (foo) where"+                            , "module M ( {- foo, -} bar ) where"+                            , "-- import A (foo)"+                            , "import B (bar)"+                            ]+                reexportCandidates "foo" source @?= []+            ]+        ]+++src :: [Text] -> Text+src = T.unlines+++test_declaresMember :: TestTree+test_declaresMember =+    testGroup+        "declaresMember"+        [ testCase "holds for a method of a locally declared class" do+            let source =+                    src+                        [ "module Data.Foldable (Foldable(..)) where"+                        , ""+                        , "class Foldable t where"+                        , "    foldr :: (a -> b -> b) -> b -> t a -> b"+                        ]+            declaresMember "foldr" source @?= True+        , testCase "does not hold for a symbol absent from the local parent" do+            let source =+                    src+                        [ "module M (Shape(..), foldr) where"+                        , "import Data.Foldable (foldr)"+                        , ""+                        , "data Shape = Circle | Square"+                        ]+            declaresMember "foldr" source @?= False+        , testCase "does not hold for a symbol merely used in the parent's body" do+            let source =+                    src+                        [ "module M (Functor(..), map) where"+                        , "import GHC.Base (map)"+                        , ""+                        , "class Functor f where"+                        , "    fmap :: (a -> b) -> f a -> f b"+                        , "    fmap = map"+                        ]+            declaresMember "map" source @?= False+        , testCase "holds for a constructor of a local data type" do+            let source =+                    src+                        [ "module M (Shape(..)) where"+                        , ""+                        , "data Shape = Circle Double | Square Double"+                        ]+            declaresMember "Square" source @?= True+        , testCase "does not hold when the parent is imported" do+            let source =+                    src+                        [ "module Prelude (Foldable(foldr)) where"+                        , "import Data.Foldable (Foldable(..))"+                        ]+            declaresMember "foldr" source @?= False+        ]+++test_exportsByName :: TestTree+test_exportsByName =+    testGroup+        "exportsByName"+        [ testCase "holds for a symbol named directly" do+            exportsByName "pack" (src ["module Data.Text (Text, pack) where"]) @?= True+        , testCase "holds for a qualified export" do+            exportsByName "pack" (src ["module M (T.pack) where", "import qualified Data.Text as T"]) @?= True+        , testCase "holds for a symbol named in a sub-list" do+            exportsByName "Just" (src ["module M (Maybe(Nothing, Just)) where"]) @?= True+        , testCase "holds for a type whose sub-list is a wildcard" do+            exportsByName "Maybe" (src ["module M (Maybe(..)) where"]) @?= True+        , testCase "does not hold for a member covered only by a wildcard" do+            exportsByName "Just" (src ["module M (Maybe(..)) where"]) @?= False+        , testCase "does not hold for a module re-export" do+            exportsByName "Maybe" (src ["module M (module GHC.Maybe) where", "import GHC.Maybe"]) @?= False+        , testCase "does not hold without an export list" do+            exportsByName "pack" (src ["module M where", "", "pack = undefined"]) @?= False+        , testCase "ignores a commented-out export" do+            exportsByName "pack" (src ["module M ( {- pack, -} unpack ) where"]) @?= False+        ]
test/Unit/Tricorder/SourceLookupSpec.hs view
@@ -1,10 +1,10 @@ module Unit.Tricorder.SourceLookupSpec (test_SourceLookup) where -import Atelier.Effects.Cache (Cache, runCacheForever) import Atelier.Effects.Env (Env, runEnvConst) import Atelier.Effects.FileSystem (FileSystem (..))-import Atelier.Effects.Input (Input, runInputConst)+import Atelier.Effects.Input (Input) import Atelier.Effects.Log (Log, runLogNoOp)+import Distribution.PackageDescription.Parsec (parseGenericPackageDescriptionMaybe) import Effectful (IOE, runEff) import Effectful.Concurrent (Concurrent, runConcurrent) import Effectful.Dispatch.Dynamic (interpret_)@@ -14,23 +14,27 @@ import Test.Tasty.HUnit (testCase, (@?=)) import Tricorder.SourceLookup.SourceQuery (ModuleName, SourceQuery (..)) +import Atelier.Effects.Input qualified as Input import Codec.Archive.Tar qualified as Tar import Codec.Archive.Tar.Entry qualified as Tar import Codec.Compression.GZip qualified as GZip import Data.ByteString.Lazy qualified as BSL-import Data.IORef qualified as IORef import Data.List qualified as List import Data.Map.Strict qualified as Map import Data.Text qualified as T +import Tricorder.Session.CabalFile (CabalFile (..)) import Tricorder.Session.Repl (Repl (..)) import Tricorder.SourceLookup     ( ModuleSourceResult (..)+    , ProjectSourceDirs (..)+    , ReexportLimits (..)+    , defaultReexportLimits     , lookupModuleSource+    , projectSourceDirs     ) import Tricorder.SourceLookup.GhcPkg (GhcPkg, GhcPkgScript (..), runGhcPkgScripted) import Tricorder.SourceLookup.Hackage (Hackage (..), Result (..))-import Tricorder.SourceLookup.PackageId (PackageId) import Tricorder.SourceLookup.PackageStore (PackageStore)  import Tricorder.SourceLookup.PackageStore qualified as PackageStore@@ -45,25 +49,28 @@             [ testCase "reads the whole module from a cached tarball" do                 result <-                     runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch-                        $ lookupModuleSource (wholeModule "Data.Aeson")+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (wholeModule "Data.Aeson")                 result @?= SourceFound (wholeModule "Data.Aeson") moduleSource             , testCase "slices a symbol (with its doc block) from a cached tarball" do                 result <-                     runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch-                        $ lookupModuleSource (symbol "Data.Aeson" "encode")+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (symbol "Data.Aeson" "encode")                 result                     @?= SourceFound                         (symbol "Data.Aeson" "encode")                         "-- | Encode a value as JSON.\nencode :: Value -> ByteString\nencode = undefined"             , testCase "returns FunctionNotFound for a symbol absent from the module" do                 result <--                    runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch-                        $ lookupModuleSource (symbol "Data.Aeson" "nope")+                    runTest+                        (NextFindModule (Just "aeson-2.2.5.0") : cycle [NextFindModule Nothing])+                        withTarball+                        noFetch+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (symbol "Data.Aeson" "nope")                 result @?= FunctionNotFound (symbol "Data.Aeson" "nope")             , testCase "returns SourceNotFound when the module is in no package" do                 result <-                     runTest [NextFindModule Nothing] Map.empty noFetch-                        $ lookupModuleSource (wholeModule "Data.Unknown")+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (wholeModule "Data.Unknown")                 result @?= SourceNotFound (wholeModule "Data.Unknown")             , testCase "fetches from Hackage on a cache miss, then reads the now-fetched tarball" do                 result <-@@ -71,65 +78,133 @@                         [NextFindModule (Just "aeson-2.2.5.0")]                         Map.empty                         (pure (Success (BSL.toStrict tarballBytes)))-                        $ lookupModuleSource (wholeModule "Data.Aeson")+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (wholeModule "Data.Aeson")                 result @?= SourceFound (wholeModule "Data.Aeson") moduleSource             , testCase "returns SourceUnavailable when the package is not found on Hackage" do                 result <-                     runTest [NextFindModule (Just "aeson-2.2.5.0")] Map.empty noFetch-                        $ lookupModuleSource (wholeModule "Data.Aeson")+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (wholeModule "Data.Aeson")                 result @?= SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"-            , testCase "caches the result so a second lookup needs no further resolution" do-                -- Only one NextFindModule is scripted; the second lookup must be served-                -- entirely from cache (module→package and package→source).-                (r1, r2) <--                    runTest [NextFindModule (Just "aeson-2.2.5.0")] withTarball noFetch $ do-                        r1 <- lookupModuleSource (wholeModule "Data.Aeson")-                        r2 <- lookupModuleSource (wholeModule "Data.Aeson")-                        pure (r1, r2)-                r1 @?= SourceFound (wholeModule "Data.Aeson") moduleSource-                r2 @?= SourceFound (wholeModule "Data.Aeson") moduleSource-            , testCase "caches an unavailable result and does not re-fetch on a repeat lookup" do-                -- The tarball is absent and every fetch reports the package as not-                -- found, so the first lookup is SourceUnavailable. A repeat lookup must-                -- be served from cache — no second Hackage fetch on the (network)-                -- request path.-                fetchCount <- IORef.newIORef (0 :: Int)-                let countingFetch = do-                        liftIO (IORef.modifyIORef' fetchCount (+ 1))+            , testCase "follows a re-export to the defining module" do+                result <-+                    runTest+                        (NextFindModule (Just "reex-1.0") : cycle [NextFindModule (Just "reex-1.0")])+                        withReexportTarball                         noFetch-                (r1, r2) <--                    runTest [NextFindModule (Just "aeson-2.2.5.0")] Map.empty countingFetch $ do-                        r1 <- lookupModuleSource (wholeModule "Data.Aeson")-                        r2 <- lookupModuleSource (wholeModule "Data.Aeson")-                        pure (r1, r2)-                r1 @?= SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"-                r2 @?= SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"-                fetches <- IORef.readIORef fetchCount-                fetches @?= 1-            , testCase "re-fetches after a failed fetch rather than caching the failure" do-                -- A failed Hackage fetch (offline, DNS failure, 5xx) is transient, so-                -- the resulting SourceUnavailable must NOT be cached: a repeat lookup-                -- has to retry the fetch, or a brief network blip pins unavailability-                -- for the whole cache window.-                fetchCount <- IORef.newIORef (0 :: Int)-                let failingFetch = do-                        liftIO (IORef.modifyIORef' fetchCount (+ 1))-                        pure (Failure "network unreachable")-                (r1, r2) <--                    runTest [NextFindModule (Just "aeson-2.2.5.0")] Map.empty failingFetch $ do-                        r1 <- lookupModuleSource (wholeModule "Data.Aeson")-                        r2 <- lookupModuleSource (wholeModule "Data.Aeson")-                        pure (r1, r2)-                r1 @?= SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"-                r2 @?= SourceUnavailable (wholeModule "Data.Aeson") "aeson-2.2.5.0"-                fetches <- IORef.readIORef fetchCount-                fetches @?= 2+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (symbol "Data.Reex" "pack")+                result+                    @?= SourceReexported+                        (symbol "Data.Reex" "pack")+                        "Data.Reex.Internal"+                        "-- | Pack a string.\npack :: String -> Text\npack = undefined"+            , testCase "follows a re-export into a module ghc-pkg cannot place" do+                -- A hidden (other-modules) module is not found by ghc-pkg; it is+                -- looked up in the re-exporting module's package instead.+                result <-+                    runTest+                        (NextFindModule (Just "reex-1.0") : cycle [NextFindModule Nothing])+                        withReexportTarball+                        noFetch+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (symbol "Data.Reex" "pack")+                result+                    @?= SourceReexported+                        (symbol "Data.Reex" "pack")+                        "Data.Reex.Internal"+                        "-- | Pack a string.\npack :: String -> Text\npack = undefined"+            , testCase "follows a chain of re-exports to the defining module" do+                -- A re-exports foo from B, which re-exports it from C, which defines it.+                result <-+                    runTest+                        (replicate 3 (NextFindModule (Just "chain-1.0")) <> cycle [NextFindModule Nothing])+                        withChainTarball+                        noFetch+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (symbol "A" "foo")+                result @?= SourceReexported (symbol "A" "foo") "C" "foo :: Int\nfoo = 42"+            , testGroup+                "re-export limits"+                -- The A -> B -> C chain takes 2 hops and reads 2 modules (B and C).+                [ testCase "finds the definition when the depth covers the chain" do+                    result <- lookupChain ReexportLimits {maxDepth = 2, maxModules = 24}+                    result @?= SourceReexported (symbol "A" "foo") "C" "foo :: Int\nfoo = 42"+                , testCase "stops short of the definition when the depth is too small" do+                    result <- lookupChain ReexportLimits {maxDepth = 1, maxModules = 24}+                    result @?= FunctionNotFound (symbol "A" "foo")+                , testCase "does not follow re-exports at depth 0" do+                    result <- lookupChain ReexportLimits {maxDepth = 0, maxModules = 24}+                    result @?= FunctionNotFound (symbol "A" "foo")+                , testCase "finds the definition when the module budget covers the chain" do+                    result <- lookupChain ReexportLimits {maxDepth = 5, maxModules = 2}+                    result @?= SourceReexported (symbol "A" "foo") "C" "foo :: Int\nfoo = 42"+                , testCase "stops short of the definition when the module budget runs out" do+                    result <- lookupChain ReexportLimits {maxDepth = 5, maxModules = 1}+                    result @?= FunctionNotFound (symbol "A" "foo")+                ]+            , testCase "returns FunctionNotFound when no re-export defines the symbol" do+                result <-+                    runTest+                        (NextFindModule (Just "reex-1.0") : cycle [NextFindModule Nothing])+                        withReexportTarball+                        noFetch+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (symbol "Data.Reex" "unpack")+                result @?= FunctionNotFound (symbol "Data.Reex" "unpack")             , testCase "finds a tarball in the legacy ~/.cabal cache location" do                 result <-                     runTest [NextFindModule (Just "aeson-2.2.5.0")] withLegacyTarball noFetch-                        $ lookupModuleSource (wholeModule "Data.Aeson")+                        $ lookupModuleSource noProjectDirs defaultReexportLimits (wholeModule "Data.Aeson")                 result @?= SourceFound (wholeModule "Data.Aeson") moduleSource             ]+        , testGroup+            "project modules"+            [ testCase "resolves every component's source dirs against the package dir" do+                projectDirs @?= ProjectSourceDirs ["/p/src", "/p/test"]+            , testCase "reads a module from the project's source dirs" do+                result <-+                    runTest [] projectFiles noFetch+                        $ lookupModuleSource projectDirs defaultReexportLimits (wholeModule "Proj.Types")+                result @?= SourceFound (wholeModule "Proj.Types") projectTypesSource+            , testCase "reads a module from a non-library component's source dirs" do+                result <-+                    runTest [] projectFiles noFetch+                        $ lookupModuleSource projectDirs defaultReexportLimits (wholeModule "Proj.Helpers")+                result @?= SourceFound (wholeModule "Proj.Helpers") projectHelpersSource+            , testCase "slices a symbol from a project module" do+                result <-+                    runTest [] projectFiles noFetch+                        $ lookupModuleSource projectDirs defaultReexportLimits (symbol "Proj.Types" "greeting")+                result+                    @?= SourceFound+                        (symbol "Proj.Types" "greeting")+                        "-- | A greeting.\ngreeting :: Text\ngreeting = \"hi\""+            , testCase "follows a re-export to another project module" do+                result <-+                    runTest [] projectFiles noFetch+                        $ lookupModuleSource projectDirs defaultReexportLimits (symbol "Proj" "greeting")+                result+                    @?= SourceReexported+                        (symbol "Proj" "greeting")+                        "Proj.Types"+                        "-- | A greeting.\ngreeting :: Text\ngreeting = \"hi\""+            , testCase "follows a re-export from a project module into a package" do+                result <-+                    runTest+                        [NextFindModule (Just "aeson-2.2.5.0")]+                        (projectFiles <> withTarball)+                        noFetch+                        $ lookupModuleSource projectDirs defaultReexportLimits (symbol "Proj" "encode")+                result+                    @?= SourceReexported+                        (symbol "Proj" "encode")+                        "Data.Aeson"+                        "-- | Encode a value as JSON.\nencode :: Value -> ByteString\nencode = undefined"+            , testCase "falls back to installed packages for a module not in the project" do+                result <-+                    runTest+                        [NextFindModule (Just "aeson-2.2.5.0")]+                        (projectFiles <> withTarball)+                        noFetch+                        $ lookupModuleSource projectDirs defaultReexportLimits (wholeModule "Data.Aeson")+                result @?= SourceFound (wholeModule "Data.Aeson") moduleSource+            ]         ]  @@ -176,11 +251,127 @@ withLegacyTarball = Map.singleton legacyTarballPath tarballBytes  +-- | A project package with a library under @src@ and a test suite under @test@.+projectCabalFile :: CabalFile+projectCabalFile =+    CabalFile "/p/proj.cabal"+        . fromMaybe (error "projectCabalFile: unparseable")+        . parseGenericPackageDescriptionMaybe+        . encodeUtf8+        $ T.unlines+            [ "cabal-version: 3.0"+            , "name: proj"+            , "version: 0.1"+            , "library"+            , "  hs-source-dirs: src"+            , "  exposed-modules: Proj, Proj.Types"+            , "test-suite spec"+            , "  type: exitcode-stdio-1.0"+            , "  main-is: Main.hs"+            , "  hs-source-dirs: test"+            , "  other-modules: Proj.Helpers"+            ]+++projectDirs :: ProjectSourceDirs+projectDirs = projectSourceDirs [projectCabalFile]+++noProjectDirs :: ProjectSourceDirs+noProjectDirs = ProjectSourceDirs []+++projectTypesSource :: Text+projectTypesSource =+    T.unlines+        [ "module Proj.Types where"+        , ""+        , "-- | A greeting."+        , "greeting :: Text"+        , "greeting = \"hi\""+        ]+++projectHelpersSource :: Text+projectHelpersSource = T.unlines ["module Proj.Helpers where", "", "helper :: Int", "helper = 1"]+++-- | The source files of 'projectCabalFile': @Proj@ re-exports @greeting@ from+-- @Proj.Types@ and @encode@ from the installed @Data.Aeson@.+projectFiles :: Map FilePath LByteString+projectFiles =+    Map.fromList+        [+            ( "/p/src/Proj.hs"+            , encodeUtf8 @Text+                "module Proj (greeting, encode) where\n\nimport Proj.Types (greeting)\nimport Data.Aeson (encode)\n"+            )+        , ("/p/src/Proj/Types.hs", encodeUtf8 projectTypesSource)+        , ("/p/test/Proj/Helpers.hs", encodeUtf8 projectHelpersSource)+        ]+++-- | A package in which @A@ re-exports @foo@ from @B@, which re-exports it from+-- @C@, which defines it.+withChainTarball :: Map FilePath LByteString+withChainTarball =+    Map.singleton+        ("/h/.cache/cabal/packages" </> "hackage.haskell.org/chain/1.0/chain-1.0.tar.gz")+        $ mkTarballWith+            [ ("chain-1.0/src/A.hs", T.unlines ["module A (foo) where", "", "import B (foo)"])+            , ("chain-1.0/src/B.hs", T.unlines ["module B (foo) where", "", "import C"])+            , ("chain-1.0/src/C.hs", T.unlines ["module C where", "", "foo :: Int", "foo = 42"])+            ]+++-- | Look up @A#foo@ in the 'withChainTarball' package under the given limits.+lookupChain :: ReexportLimits -> IO ModuleSourceResult+lookupChain limits =+    runTest+        (replicate 3 (NextFindModule (Just "chain-1.0")) <> cycle [NextFindModule Nothing])+        withChainTarball+        noFetch+        $ lookupModuleSource noProjectDirs limits (symbol "A" "foo")+++-- | A package whose @Data.Reex@ re-exports @pack@ (and claims to re-export+-- @unpack@) from @Data.Reex.Internal@, which only defines @pack@.+withReexportTarball :: Map FilePath LByteString+withReexportTarball =+    Map.singleton+        ("/h/.cache/cabal/packages" </> "hackage.haskell.org/reex/1.0/reex-1.0.tar.gz")+        $ mkTarballWith+            [+                ( "reex-1.0/src/Data/Reex.hs"+                , T.unlines+                    [ "module Data.Reex (Text, pack, unpack) where"+                    , ""+                    , "import Data.Reex.Internal (Text, pack, unpack)"+                    ]+                )+            ,+                ( "reex-1.0/src/Data/Reex/Internal.hs"+                , T.unlines+                    [ "module Data.Reex.Internal where"+                    , ""+                    , "-- | Pack a string."+                    , "pack :: String -> Text"+                    , "pack = undefined"+                    ]+                )+            ]++ mkTarball :: FilePath -> Text -> LByteString-mkTarball entryPath content =-    GZip.compress (Tar.write [Tar.fileEntry tarPath (BSL.fromStrict (encodeUtf8 content))])+mkTarball entryPath content = mkTarballWith [(entryPath, content)]+++mkTarballWith :: [(FilePath, Text)] -> LByteString+mkTarballWith entries =+    GZip.compress+        (Tar.write [Tar.fileEntry (tarPath p) (BSL.fromStrict (encodeUtf8 c)) | (p, c) <- entries])   where-    tarPath = either (error . toText) id (Tar.toTarPath False entryPath)+    tarPath p = either (error . toText) id (Tar.toTarPath False p)   wholeModule :: ModuleName -> SourceQuery@@ -204,49 +395,38 @@ noFetch = pure NotFound  +type FetchEffects =+    '[ PackageStore+     , Env+     , FileSystem+     , State (Map FilePath LByteString)+     , Input Repl+     , Log+     , Concurrent+     , IOE+     ]+++type TestEffects = GhcPkg : Hackage : FetchEffects++ runTest     :: [GhcPkgScript]     -> Map FilePath LByteString-    -> Eff-        '[ PackageStore-         , Env-         , FileSystem-         , State (Map FilePath LByteString)-         , Input Repl-         , Log-         , Concurrent-         , IOE-         ]-        Result-    -> Eff-        '[ Cache ModuleName PackageId-         , Cache (PackageId, SourceQuery) ModuleSourceResult-         , GhcPkg-         , Hackage-         , PackageStore-         , Env-         , FileSystem-         , State (Map FilePath LByteString)-         , Input Repl-         , Log-         , Concurrent-         , IOE-         ]-        a+    -> Eff FetchEffects Result+    -> Eff TestEffects a     -> IO a runTest pkgScript initialFs onFetch action =     runEff         . runConcurrent         . runLogNoOp-        . runInputConst Cabal+        . Input.withConst Cabal         . evalState initialFs         . runFileSystemFake         . runEnvConst [("HOME", "/h")]         . PackageStore.run         . runHackageWith onFetch         . runGhcPkgScripted pkgScript-        . runCacheForever @(PackageId, SourceQuery) @ModuleSourceResult-        . runCacheForever @ModuleName @PackageId         $ action  
tricorder.cabal view
@@ -5,7 +5,7 @@ -- see: https://github.com/sol/hpack  name:            tricorder-version:         0.5.0.0+version:         0.6.0.0 synopsis:        Continuous Haskell build status, diagnostics, and tests via a shared daemon description:     tricorder rebuilds your Haskell project continuously and surfaces build status, diagnostics, test results, and documentation - for developers and LLM coding agents. Like ghcid and ghciwatch it reloads on every change, but builds run in a background daemon so multiple clients (an interactive TUI, a status CLI, an agent skill) share a single build state without triggering redundant rebuilds. It discovers components across multi-package cabal.project workspaces automatically and ships context-friendly output for agentic use via the CLI. category:        Development@@ -39,9 +39,20 @@       Tricorder.Build.EvalComment       Tricorder.Build.Test       Tricorder.CLI.App+      Tricorder.CLI.App.CLIResponse+      Tricorder.CLI.App.Output+      Tricorder.CLI.App.Test+      Tricorder.CLI.App.Test.Json+      Tricorder.CLI.App.Test.Json.Responses+      Tricorder.CLI.App.Test.SuiteView+      Tricorder.CLI.App.Test.Text       Tricorder.CLI.Arguments       Tricorder.CLI.Arguments.Daemon+      Tricorder.CLI.Arguments.IfDaemonIsStopped       Tricorder.CLI.Arguments.OutputFormat+      Tricorder.CLI.Arguments.Test+      Tricorder.CLI.Arguments.TestFilter+      Tricorder.CLI.Arguments.WaitMode       Tricorder.CLI.Daemon       Tricorder.CLI.Main       Tricorder.CLI.Operations@@ -56,14 +67,33 @@       Tricorder.CLI.UI.State       Tricorder.CLI.UI.View       Tricorder.Config+      Tricorder.Config.LoadedConfig       Tricorder.Daemon.Builder       Tricorder.Daemon.Core       Tricorder.Daemon.DaemonInfo       Tricorder.Daemon.Dispatch       Tricorder.Daemon.EvalCommentRunner-      Tricorder.Daemon.GhciSession-      Tricorder.Daemon.GhciSession.GhciParser-      Tricorder.Daemon.GhciSession.GhciProcess+      Tricorder.Daemon.Ghci.Config+      Tricorder.Daemon.Ghci.Controls+      Tricorder.Daemon.Ghci.Diagnostic+      Tricorder.Daemon.Ghci.InterruptDecision+      Tricorder.Daemon.Ghci.LineParser+      Tricorder.Daemon.Ghci.Load+      Tricorder.Daemon.Ghci.LoadedModule+      Tricorder.Daemon.Ghci.Loading+      Tricorder.Daemon.Ghci.LoadOutcome+      Tricorder.Daemon.Ghci.LoadResult+      Tricorder.Daemon.Ghci.Message+      Tricorder.Daemon.Ghci.Output+      Tricorder.Daemon.Ghci.Path+      Tricorder.Daemon.Ghci.Position+      Tricorder.Daemon.Ghci.Process+      Tricorder.Daemon.Ghci.Result+      Tricorder.Daemon.Ghci.Session+      Tricorder.Daemon.Ghci.Session.State+      Tricorder.Daemon.Ghci.Severity+      Tricorder.Daemon.Ghci.TextParser+      Tricorder.Daemon.HookDispatch       Tricorder.Daemon.Hpack       Tricorder.Daemon.Hpack.Effect       Tricorder.Daemon.IdleTimer@@ -79,6 +109,8 @@       Tricorder.Session.CommandTemplate       Tricorder.Session.Config       Tricorder.Session.GenerateWithHpack+      Tricorder.Session.HaskellHooks+      Tricorder.Session.Hook       Tricorder.Session.Hooks       Tricorder.Session.IdleTimeout       Tricorder.Session.Repl@@ -92,6 +124,9 @@       Tricorder.Session.Stage.Test.Command       Tricorder.Session.Stage.Test.Config       Tricorder.Session.Stage.Test.Session+      Tricorder.Session.State+      Tricorder.Session.State.Invalid+      Tricorder.Session.State.Result       Tricorder.Session.Target       Tricorder.Session.TestTarget       Tricorder.Session.TestTimeout@@ -107,6 +142,7 @@       Tricorder.SourceLookup.Hackage       Tricorder.SourceLookup.PackageId       Tricorder.SourceLookup.PackageStore+      Tricorder.SourceLookup.Reexport       Tricorder.SourceLookup.Slice       Tricorder.SourceLookup.Tarball       Tricorder.TestOutput@@ -141,7 +177,7 @@     , Cabal-syntax >=3.12 && <3.17     , Glob ==0.10.*     , aeson >=2.2 && <2.4-    , atelier-core ==0.7.*+    , atelier-core ==0.8.*     , atelier-prelude ==0.4.*     , base >=4.18 && <4.23     , brick ==3.0.*@@ -170,7 +206,7 @@     , text ==2.1.*     , time >=1.12 && <1.17     , time-units ==1.0.*-    , tricorder-types ==0.4.*+    , tricorder-types ==0.5.*     , vty >=6.5 && <6.7     , vty-crossplatform ==0.5.*     , yaml ==0.11.*@@ -263,13 +299,16 @@   other-modules:       Unit.Tricorder.Build.ByteSizeSpec       Unit.Tricorder.Build.EvalCommentSpec+      Unit.Tricorder.CLI.App.Test.SuiteViewSpec       Unit.Tricorder.CLI.RenderSpec       Unit.Tricorder.Daemon.BuilderSpec       Unit.Tricorder.Daemon.BuildStateSpec       Unit.Tricorder.Daemon.DispatchSpec-      Unit.Tricorder.Daemon.GhciSession.GhciParserSpec-      Unit.Tricorder.Daemon.GhciSession.GhciProcessSpec-      Unit.Tricorder.Daemon.GhciSessionSpec+      Unit.Tricorder.Daemon.Ghci.LoadResultSpec+      Unit.Tricorder.Daemon.Ghci.OutputSpec+      Unit.Tricorder.Daemon.Ghci.ProcessSpec+      Unit.Tricorder.Daemon.Ghci.ResultSpec+      Unit.Tricorder.Daemon.Ghci.SessionSpec       Unit.Tricorder.Daemon.TestRunnerSpec       Unit.Tricorder.Daemon.WatchSpec       Unit.Tricorder.Session.CabalFileSpec@@ -285,6 +324,7 @@       Unit.Tricorder.SessionSpec       Unit.Tricorder.SocketSpec       Unit.Tricorder.SourceLookup.GhcPkgSpec+      Unit.Tricorder.SourceLookup.ReexportSpec       Unit.Tricorder.SourceLookup.SliceSpec       Unit.Tricorder.SourceLookup.TarballSpec       Unit.Tricorder.SourceLookupSpec@@ -318,7 +358,7 @@   build-depends:       Cabal-syntax >=3.12 && <3.17     , aeson >=2.2 && <2.4-    , atelier-core ==0.7.*+    , atelier-core ==0.8.*     , atelier-prelude ==0.4.*     , base >=4.18 && <4.23     , bytestring >=0.11 && <0.13@@ -340,7 +380,7 @@     , time >=1.12 && <1.17     , time-units ==1.0.*     , tricorder-internal-    , tricorder-types ==0.4.*+    , tricorder-types ==0.5.*     , typed-process ==0.2.*     , unagi-chan ==0.4.*     , unix ==2.8.*