packages feed

tricorder-0.5.0.0: src/Tricorder/Daemon/Core.hs

module Tricorder.Daemon.Core (main) 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 (FileEvent, FileWatcher)
import Atelier.Effects.Input (Input, input)
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 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 Data.Map.Strict qualified as Map
import Effectful.Reader.Static qualified as Reader
import Effectful.State.Static.Shared qualified as State

import Tricorder.Build (BuildId, BuildPhase, BuildResult, PostBuild (..), Severity (..))
import Tricorder.Build.ByteSize (ByteSize)
import Tricorder.Build.Changes (CabalChangeDetected (..), SourceChangeDetected (..))
import Tricorder.Daemon.Builder
    ( BuildConsideration (..)
    , BuildFailure
    , Builder
    , NewLoadResult
    , compileBuildResults
    )
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.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 (..))
import Tricorder.Session.TestTimeout (TestTimeout (..))
import Tricorder.Waiters (Waiters)

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.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.Hooks qualified as Hooks
import Tricorder.Session.Stage.Build.Command qualified as BuildCommand
import Tricorder.Session.Stage.Test.Command qualified as TestCommand
import Tricorder.Session.TestTarget qualified as TestTarget
import Tricorder.Waiters qualified as Waiters


data ReloadSession = ReloadSession


data RestartBuilder = RestartBuilder


data ReloadBuilder = ReloadBuilder FilePath FileEvent


-- | Top of the build loop. Responsible for handling changes to the Tricorder
-- config, as well as setting up other build-specific effects.
main
    :: ( 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
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
            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


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


-- | Starts the initial build with GHCi, and waits for source changes.
runBuilder
    :: ( Clock :> es
       , Conc :> es
       , Concurrent :> es
       , EvalCommentRunner :> es
       , GhciSession :> 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
    Log.info $ "Starting session " <> show buildId.getBuildId
    Pub.publish Build.Starting
    whenJust (session.hooks.start >>= (.before)) Hooks.runHook
    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
            processPostBuild session $ Right initialLoad
            Log.debug "Waiting for reload"
            newestReloadEvent <- atomically newEmptyTMVar
            cancelSem <- newEmptyMVar
            let requestCancel = tryPutMVar cancelSem ()
                checkCancel = takeMVar cancelSem
            Conc.fork_ $ Sub.listen_ @ReloadBuilder \event -> do
                atomically $ writeTMVar newestReloadEvent event
                Waiters.without do
                    Pub.publish Build.Starting
                    Log.debug "Cancelling current build"
                    requestCancel
            forever do
                event <- atomically $ takeTMVar newestReloadEvent
                Conc.restartableFork checkCancel
                    $ waitForReload session event

    Pub.publish $ Build.Failed $ show startupError
  where
    buildCommand = BuildCommand.render session.buildSession.commandTemplate session.buildSession.targets


-- | Handles source changes as they come, determining whether the source change
-- detected warrants a rebuild.
waitForReload
    :: ( Builder :> es
       , Conc :> es
       , EvalCommentRunner :> es
       , Log :> es
       , Process :> es
       , Pub BuildPhase :> es
       , Reader ProjectRoot :> es
       , State BuilderState :> es
       , TestRunner :> es
       )
    => Session -> ReloadBuilder -> Eff es ()
waitForReload session (ReloadBuilder fp event) = do
    Log.debug $ "Considering " <> toText fp
    consideration <- Builder.consider fp event
    case consideration of
        SkipBuilding -> do
            Log.debug $ "Skipping " <> toText fp
            pure ()
        ShouldBuild action -> do
            Log.debug $ "Should build " <> toText fp
            processSource session action
    Log.info "Reload finished"


-- | Rebuilds the project on source change.
processSource
    :: ( Builder :> es
       , Conc :> es
       , EvalCommentRunner :> es
       , Log :> es
       , Process :> es
       , Pub BuildPhase :> es
       , Reader ProjectRoot :> es
       , State BuilderState :> es
       , TestRunner :> es
       )
    => Session
    -> DispatchAction
    -> Eff es ()
processSource session action = do
    whenJust (session.hooks.reload >>= (.before)) Hooks.runHook
    res <- Builder.build action
    whenJust (session.hooks.reload >>= (.after)) Hooks.runHook
    Log.debug "Finished build"
    processPostBuild session res


processPostBuild
    :: ( Conc :> es
       , EvalCommentRunner :> es
       , Log :> es
       , Pub BuildPhase :> es
       , Reader ProjectRoot :> es
       , State BuilderState :> es
       , TestRunner :> es
       )
    => Session -> Either BuildFailure NewLoadResult -> Eff es ()
processPostBuild session = \case
    Left buildFailure -> do
        Log.debug "Build failure"
        Pub.publish $ Build.Failed $ show buildFailure
    Right newLoadResult -> do
        Log.debug "Built"
        buildResult <- newLoadResultToBuildResult session newLoadResult
        let initialPostBuild = PostBuild mempty Eval.Looking
        Pub.publish $ Build.PostBuilding buildResult initialPostBuild
        State.evalState initialPostBuild $ Conc.scoped do
            evalCommentsP <-
                Conc.fork
                    $ Pub.consume (updateEvalComments buildResult)
                    $ runEvalComments session newLoadResult.loadResult
            testsP <-
                Conc.fork
                    $ Pub.consume (updateTestSuites buildResult)
                    $ runTests session buildResult
            evalComments <- Conc.await evalCommentsP
            Log.debug "Eval comments finished"
            tests <- Conc.await testsP
            Log.debug "Tests finished"
            Pub.publish $ Build.Finished buildResult $ PostBuild tests evalComments
  where
    updateEvalComments buildResult evalComments = do
        newPostBuild <- State.state \postBuild -> dup $ postBuild {evalComments}
        Pub.publish $ Build.PostBuilding buildResult newPostBuild
    updateTestSuites buildResult testSuites = do
        newPostBuild <- State.state \postBuild -> dup $ postBuild {testSuites}
        Pub.publish $ Build.PostBuilding buildResult newPostBuild


runEvalComments
    :: ( EvalCommentRunner :> es
       , Pub Eval.Phase :> es
       , State BuilderState :> es
       )
    => Session -> LoadResult -> Eff es Eval.Phase
runEvalComments session loadResult = do
    builderState <- State.get @BuilderState
    evalComments <-
        findEvalCommentsInModules $ resolveKnownTargets builderState.loadedModules loadResult

    case nonEmpty evalComments of
        Nothing -> pure Eval.NoneFound
        Just nonEmptyComments -> do
            let pendingComments =
                    sconcat $ (\(lm, ecs) -> toPending lm.relPath <$> ecs) <$> nonEmptyComments
            Pub.publish $ Eval.Found $ Eval.Comments pendingComments
            evaluatedComments <- EvalCommentRunner.evaluateComments session.evalSession nonEmptyComments
            pure $ Eval.Found $ Eval.Comments evaluatedComments
  where
    toPending file comment =
        Eval.Evaluation
            { file
            , comment
            , state = Eval.Pending
            }


runTests
    :: ( Log :> es
       , Pub Test.Suites :> es
       , TestRunner :> es
       )
    => Session -> BuildResult -> Eff es Test.Suites
runTests session buildResult
    | hasTargets session.testSession.targets && noErrors buildResult.diagnostics =
        runTestsForTargets
            session.testSession
            session.testMemoryLimit
            session.testTimeout
    | otherwise = pure mempty
  where
    hasTargets = not . null
    noErrors = all \d -> d.severity /= SError


runTestsForTargets
    :: ( Log :> es
       , Pub Test.Suites :> es
       , TestRunner :> es
       )
    => TestSession
    -> Maybe ByteSize
    -> TestTimeout
    -> Eff es Test.Suites
runTestsForTargets testSession memoryLimit testTimeout = do
    Pub.publish $ Test.Suites initial
    Log.info $ "Running " <> show (length testSession.targets) <> " test suite(s)"
    fmap Test.Suites . State.execState initial $ traverse_ go testSession.targets
  where
    initial = Map.fromList $ (,Test.SuiteRunning Nothing) <$> testSession.targets
    go target = do
        Log.info $ "Running tests: " <> renderedTarget
        let testCommand = TestCommand.render testSession memoryLimit target
            publishProgress suite = do
                updated <- State.state $ dup . Map.insert target suite
                Pub.publish $ Test.Suites updated
        Log.info
            $ "Test suite "
                <> renderedTarget
                <> " command:\n"
                <> show testCommand.command
        finishedSuite <- TestRunner.runTestSuite publishProgress testTimeout testCommand
        case finishedSuite of
            Test.SuiteErrored (Test.SuiteError message) ->
                Log.warn $ "Test suite " <> renderedTarget <> "failed: " <> message
            _ ->
                pure ()
        updated <- State.state $ dup . Map.insert target finishedSuite
        Pub.publish $ Test.Suites updated
      where
        renderedTarget = TestTarget.render target


newLoadResultToBuildResult
    :: (Reader ProjectRoot :> es, State BuilderState :> es)
    => Session -> NewLoadResult -> Eff es BuildResult
newLoadResultToBuildResult session newLoadResult = do
    root <- Reader.ask
    State.state \s ->
        let (newDiagnosticMap, buildResult) =
                compileBuildResults
                    root
                    session.watchDirs
                    s.diagnosticMap
                    newLoadResult
        in  (buildResult, s {diagnosticMap = newDiagnosticMap})


logSession :: (Log :> es) => Session -> Eff es ()
logSession = Log.info . (<> "\n") . Session.show