packages feed

tricorder-0.1.1.0: src/Tricorder/Watcher.hs

module Tricorder.Watcher
    ( component
    , WatchedFile (..)
    , WatcherSession (..)
    , isCabalFile
    , makeWatches
    , markWatchedFiles
    ) where

import Atelier.Component (Component (..), defaultComponent)
import Atelier.Effects.Chan (Chan)
import Atelier.Effects.Conc (Conc)
import Atelier.Effects.Debounce (Debounce)
import Atelier.Effects.FileWatcher
    ( FileEvent
    , FileWatcher
    , Watch
    , containing
    , dirExt
    , dirWhere
    , excluding
    , watchFilePathsDebounced
    )
import Atelier.Effects.Publishing (Pub, Sub, publish)
import Effectful.Concurrent (Concurrent)
import Effectful.Reader.Static (Reader, ask)
import System.FilePath (takeExtension, takeFileName)
import Text.Regex.TDFA (ExecOption (..), blankCompOpt, blankExecOpt, match)
import Text.Regex.TDFA.TDFA (patternToRegex)

import Atelier.Effects.Publishing qualified as Sub

import Tricorder.BuildState
    ( CabalChangeDetected (..)
    , ChangeKind (..)
    , SourceChangeDetected (..)
    )
import Tricorder.Effects.BuildStore (BuildStore)
import Tricorder.Effects.SessionStore (SessionStore, SessionStoreReloaded)
import Tricorder.Runtime (ProjectRoot (..))
import Tricorder.Session (Pattern, Session (..), WatchDirs (..), WatchExclusionPatterns (..))

import Tricorder.Effects.BuildStore qualified as BuildStore
import Tricorder.Effects.SessionStore qualified as SessionStore


-- | Watcher component.
-- Watches source files and cabal-related files for changes, setting the dirty
-- flag in 'BuildStore'. 'GhciSession' polls this flag and triggers a rebuild
-- or session restart accordingly.
component
    :: ( BuildStore :> es
       , Chan :> es
       , Conc :> es
       , Concurrent :> es
       , Debounce FilePath :> es
       , FileWatcher :> es
       , Pub CabalChangeDetected :> es
       , Pub SourceChangeDetected :> es
       , Pub WatchedFile :> es
       , Reader ProjectRoot :> es
       , SessionStore :> es
       , Sub SessionStoreReloaded :> es
       , Sub WatchedFile :> es
       )
    => Component es
component =
    defaultComponent
        { name = "Watcher"
        , triggers = pure [watchFiles]
        , listeners = pure [Sub.listen_ markWatchedFiles]
        }


markWatchedFiles
    :: ( BuildStore :> es
       , Pub CabalChangeDetected :> es
       , Pub SourceChangeDetected :> es
       )
    => WatchedFile -> Eff es ()
markWatchedFiles f = do
    BuildStore.markDirty change
    case change of
        CabalChange -> publish (CabalChangeDetected f.path f.event)
        SourceChange -> publish (SourceChangeDetected f.path f.event)
  where
    change = changeKindFor f.path


data WatchedFile = WatchedFile
    { path :: FilePath
    , event :: FileEvent
    }


data WatcherSession = WatcherSession
    { watchDirs :: WatchDirs
    , watchExclusionPatterns :: WatchExclusionPatterns
    }
    deriving stock (Eq)


withWatcherSession
    :: ( Chan :> es
       , Conc :> es
       , Concurrent :> es
       , SessionStore :> es
       , Sub SessionStoreReloaded :> es
       )
    => Session
    -> (SessionStore.Reloader es -> WatcherSession -> Eff es Void)
    -> Eff es Void
withWatcherSession =
    SessionStore.withSubSession $ \session ->
        WatcherSession
            { watchDirs = session.watchDirs
            , watchExclusionPatterns = session.watchExclusionPatterns
            }


watchFiles
    :: ( Chan :> es
       , Conc :> es
       , Concurrent :> es
       , Debounce FilePath :> es
       , FileWatcher :> es
       , Pub WatchedFile :> es
       , Reader ProjectRoot :> es
       , SessionStore :> es
       , Sub SessionStoreReloaded :> es
       )
    => Eff es Void
watchFiles = do
    initialSession <- SessionStore.get
    withWatcherSession initialSession $ \_ session -> do
        projectRoot <- ask
        let watches = makeWatches projectRoot session
        watchFilePathsDebounced watches \filePath fileEvent -> publish (WatchedFile filePath fileEvent)


makeWatches :: ProjectRoot -> WatcherSession -> [Watch]
makeWatches projectRoot session =
    sourceWatches (coerce session.watchExclusionPatterns) (coerce session.watchDirs)
        <> cabalWatches projectRoot


sourceWatches :: [Pattern] -> [FilePath] -> [Watch]
sourceWatches exclusionPatterns =
    map \d ->
        dirExt d ".hs"
            `excluding` containing "dist-newstyle"
            `excluding` exclusionMatches exclusionPatterns


exclusionMatches :: [Pattern] -> FilePath -> Bool
exclusionMatches exclusionPatterns fp = any matchPattern exclusionPatterns
  where
    matchPattern p =
        match
            (patternToRegex p blankCompOpt blankExecOpt {captureGroups = False})
            fp


cabalWatches :: ProjectRoot -> [Watch]
cabalWatches (ProjectRoot projectRoot) =
    [dirWhere projectRoot isCabalFile `excluding` containing "dist-newstyle"]


isCabalFile :: FilePath -> Bool
isCabalFile f =
    takeExtension f == ".cabal"
        || takeFileName f `elem` ["cabal.project", "package.yaml"]


changeKindFor :: FilePath -> ChangeKind
changeKindFor path
    | isCabalFile path = CabalChange
    | otherwise = SourceChange