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 +67/−0
- src/Tricorder/Build/EvalComment.hs +1/−1
- src/Tricorder/Build/Test.hs +9/−10
- src/Tricorder/CLI/App.hs +8/−10
- src/Tricorder/CLI/App/CLIResponse.hs +16/−0
- src/Tricorder/CLI/App/Output.hs +57/−0
- src/Tricorder/CLI/App/Test.hs +145/−0
- src/Tricorder/CLI/App/Test/Json.hs +63/−0
- src/Tricorder/CLI/App/Test/Json/Responses.hs +79/−0
- src/Tricorder/CLI/App/Test/SuiteView.hs +154/−0
- src/Tricorder/CLI/App/Test/Text.hs +99/−0
- src/Tricorder/CLI/Arguments.hs +64/−28
- src/Tricorder/CLI/Arguments/IfDaemonIsStopped.hs +14/−0
- src/Tricorder/CLI/Arguments/OutputFormat.hs +5/−3
- src/Tricorder/CLI/Arguments/Test.hs +106/−0
- src/Tricorder/CLI/Arguments/TestFilter.hs +17/−0
- src/Tricorder/CLI/Arguments/WaitMode.hs +12/−0
- src/Tricorder/CLI/Main.hs +33/−28
- src/Tricorder/CLI/Operations.hs +28/−18
- src/Tricorder/CLI/Render.hs +6/−1
- src/Tricorder/CLI/UI.hs +15/−9
- src/Tricorder/CLI/UI/Keys.hs +102/−96
- src/Tricorder/CLI/UI/Route.hs +0/−2
- src/Tricorder/CLI/UI/State.hs +2/−0
- src/Tricorder/CLI/UI/View.hs +63/−45
- src/Tricorder/Config.hs +29/−47
- src/Tricorder/Config/LoadedConfig.hs +92/−0
- src/Tricorder/Daemon/Builder.hs +17/−14
- src/Tricorder/Daemon/Core.hs +31/−162
- src/Tricorder/Daemon/DaemonInfo.hs +5/−3
- src/Tricorder/Daemon/Dispatch.hs +14/−15
- src/Tricorder/Daemon/EvalCommentRunner.hs +9/−8
- src/Tricorder/Daemon/Ghci/Config.hs +25/−0
- src/Tricorder/Daemon/Ghci/Controls.hs +25/−0
- src/Tricorder/Daemon/Ghci/Diagnostic.hs +56/−0
- src/Tricorder/Daemon/Ghci/InterruptDecision.hs +28/−0
- src/Tricorder/Daemon/Ghci/LineParser.hs +66/−0
- src/Tricorder/Daemon/Ghci/Load.hs +143/−0
- src/Tricorder/Daemon/Ghci/LoadOutcome.hs +7/−0
- src/Tricorder/Daemon/Ghci/LoadResult.hs +54/−0
- src/Tricorder/Daemon/Ghci/LoadedModule.hs +13/−0
- src/Tricorder/Daemon/Ghci/Loading.hs +13/−0
- src/Tricorder/Daemon/Ghci/Message.hs +17/−0
- src/Tricorder/Daemon/Ghci/Output.hs +57/−0
- src/Tricorder/Daemon/Ghci/Path.hs +41/−0
- src/Tricorder/Daemon/Ghci/Position.hs +56/−0
- src/Tricorder/Daemon/Ghci/Process.hs +547/−0
- src/Tricorder/Daemon/Ghci/Result.hs +230/−0
- src/Tricorder/Daemon/Ghci/Session.hs +131/−0
- src/Tricorder/Daemon/Ghci/Session/State.hs +5/−0
- src/Tricorder/Daemon/Ghci/Severity.hs +20/−0
- src/Tricorder/Daemon/Ghci/TextParser.hs +19/−0
- src/Tricorder/Daemon/GhciSession.hs +0/−175
- src/Tricorder/Daemon/GhciSession/GhciParser.hs +0/−674
- src/Tricorder/Daemon/GhciSession/GhciProcess.hs +0/−590
- src/Tricorder/Daemon/HookDispatch.hs +34/−0
- src/Tricorder/Daemon/Hpack.hs +14/−11
- src/Tricorder/Daemon/Main.hs +52/−37
- src/Tricorder/Daemon/TestRunner.hs +15/−18
- src/Tricorder/Daemon/Watch.hs +2/−2
- src/Tricorder/Logging.hs +27/−11
- src/Tricorder/Session.hs +39/−42
- src/Tricorder/Session/CabalFile.hs +9/−7
- src/Tricorder/Session/CommandConfig.hs +1/−1
- src/Tricorder/Session/CommandTemplate.hs +7/−9
- src/Tricorder/Session/Config.hs +3/−2
- src/Tricorder/Session/HaskellHooks.hs +43/−0
- src/Tricorder/Session/Hook.hs +31/−0
- src/Tricorder/Session/Hooks.hs +46/−27
- src/Tricorder/Session/StackProject.hs +3/−2
- src/Tricorder/Session/Stage/Build/Session.hs +6/−7
- src/Tricorder/Session/Stage/Eval/Session.hs +4/−6
- src/Tricorder/Session/Stage/Test/Config.hs +1/−1
- src/Tricorder/Session/Stage/Test/Session.hs +7/−9
- src/Tricorder/Session/State.hs +173/−0
- src/Tricorder/Session/State/Invalid.hs +10/−0
- src/Tricorder/Session/State/Result.hs +43/−0
- src/Tricorder/Session/TestTarget.hs +9/−10
- src/Tricorder/Session/Util.hs +3/−3
- src/Tricorder/Session/WatchDirs.hs +16/−0
- src/Tricorder/Socket/Client.hs +1/−0
- src/Tricorder/Socket/Server.hs +28/−28
- src/Tricorder/Socket/UnixSocket.hs +23/−63
- src/Tricorder/SourceLookup.hs +227/−60
- src/Tricorder/SourceLookup/Reexport.hs +343/−0
- src/Tricorder/SourceLookup/Tarball.hs +1/−0
- test/Unit/Tricorder/CLI/App/Test/SuiteViewSpec.hs +119/−0
- test/Unit/Tricorder/Daemon/BuilderSpec.hs +2/−151
- test/Unit/Tricorder/Daemon/DispatchSpec.hs +2/−1
- test/Unit/Tricorder/Daemon/Ghci/LoadResultSpec.hs +87/−0
- test/Unit/Tricorder/Daemon/Ghci/OutputSpec.hs +78/−0
- test/Unit/Tricorder/Daemon/Ghci/ProcessSpec.hs +564/−0
- test/Unit/Tricorder/Daemon/Ghci/ResultSpec.hs +477/−0
- test/Unit/Tricorder/Daemon/Ghci/SessionSpec.hs +152/−0
- test/Unit/Tricorder/Daemon/GhciSession/GhciParserSpec.hs +0/−503
- test/Unit/Tricorder/Daemon/GhciSession/GhciProcessSpec.hs +0/−569
- test/Unit/Tricorder/Daemon/GhciSessionSpec.hs +0/−154
- test/Unit/Tricorder/Daemon/WatchSpec.hs +1/−1
- test/Unit/Tricorder/Session/CabalFileSpec.hs +2/−2
- test/Unit/Tricorder/Session/TestTargetSpec.hs +5/−5
- test/Unit/Tricorder/SessionSpec.hs +42/−23
- test/Unit/Tricorder/SocketSpec.hs +16/−34
- test/Unit/Tricorder/SourceLookup/ReexportSpec.hs +181/−0
- test/Unit/Tricorder/SourceLookupSpec.hs +269/−89
- tricorder.cabal +51/−11
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.*