packages feed

tricorder-0.1.1.0: src/Tricorder/Builder/Dispatch.hs

module Tricorder.Builder.Dispatch
    ( BuilderState (..)
    , DiagnosticMap
    , DispatchAction (..)
    , KnownTargetNames (..)
    , dispatch
    , emptyBuilderState
    , fileMatchesAnyTarget
    , filterToWatchDirs
    , mergeDiagnostics
    , preserveFailureVisibility
    ) where

import Atelier.Effects.FileWatcher (FileEvent (..))
import Data.Default (Default (..))
import System.FilePath (isAbsolute, normalise, splitDirectories, takeExtension, (</>))

import Data.List qualified as List
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set

import Tricorder.BuildState (Diagnostic (..), Severity (..))
import Tricorder.Effects.GhciSession (LoadResult (..), LoadedModule (..))
import Tricorder.Effects.GhciSession.GhciParser
    ( isLocationLess
    , pathSuffixesAsModuleName
    , unattributedFailure
    )
import Tricorder.Session (WatchDirs (..))


-- | 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
    , knownTargets :: KnownTargetNames
    , diagnosticMap :: DiagnosticMap
    }
    deriving stock (Eq, Show)


instance Default BuilderState where
    def = emptyBuilderState


emptyBuilderState :: BuilderState
emptyBuilderState =
    BuilderState
        { loadedModules = mempty
        , knownTargets = KnownTargetNames mempty
        , diagnosticMap = mempty
        }


type DiagnosticMap = Map FilePath [Diagnostic]


-- | Merge a new 'LoadResult' into the accumulated per-file diagnostic map.
--
-- Files in 'compiledFiles' have their previous diagnostics cleared and replaced
-- by any new diagnostics produced for them in this cycle. Files absent from
-- 'compiledFiles' were skipped by incremental compilation and retain their
-- previous diagnostics unchanged.
--
-- Location-less diagnostics (see 'isLocationLess') are never keyed to a real
-- source file, so they would never appear in 'compiledFiles' and would persist
-- 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
        cleared = foldr Map.delete retained compiledFiles
        newByFile = Map.fromListWith (++) [(d.file, [d]) | d <- diagnostics]
    in  Map.union newByFile cleared


-- | GHCi's current target set, as raw entries from @:show targets@
-- (typically dotted module names under @cabal repl --enable-multi-repl@).
-- Survives every compile failure mode, so the dispatcher can recognise a
-- target even when it's absent from the path-keyed module map.
newtype KnownTargetNames = KnownTargetNames {unKnownTargetNames :: Set Text}
    deriving stock (Eq, Show)


-- | Whether a file path corresponds to one of GHCi's targets.
--
-- @:show targets@ entries are either dotted module names (e.g.
-- @Tricorder.CLI.Main@) or file paths (e.g. @app/Main.hs@). GHCi uses the path
-- form when a module name is ambiguous across home units — i.e. every
-- executable/test 'Main'. We match both forms.
--
-- The path form matters because a /failed/ executable 'Main' drops out of
-- @:show modules@ but survives in @:show targets@ as its path. Without matching
-- it, fixing the executable would dispatch a no-op 'Add' instead of a 'Reload',
-- leaving the diagnostic stale.
fileMatchesAnyTarget :: KnownTargetNames -> FilePath -> Bool
fileMatchesAnyTarget (KnownTargetNames targets) fp =
    any (`Set.member` targets) (pathSuffixesAsModuleName fp)
        || any (pathTargetMatches fp . toString) (Set.toList targets)


-- | Whether a path-shaped @:show targets@ entry refers to the given file,
-- compared on directory-segment boundaries (so @app/Main.hs@ matches
-- @./tricorder/app/Main.hs@ but @pp/Main.hs@ does not). Module-name targets
-- (no @.hs@ extension) are left to the module-name branch above.
pathTargetMatches :: FilePath -> FilePath -> Bool
pathTargetMatches file target =
    takeExtension target == ".hs"
        && splitDirectories (normalise target) `List.isSuffixOf` splitDirectories (normalise file)


-- | A GHCi command to issue in response to a source file change.
data DispatchAction
    = Reload
    | Add FilePath
    | Unadd Text
    deriving stock (Eq, Show)


-- | Decide what GHCi action a source-file change requires.
--
-- The path-keyed module map misses targets that failed on first load,
-- so we fall back to 'KnownTargetNames' for those — otherwise we would
-- issue @:add@ (a no-op for an already-tracked cabal target), leaving
-- stale diagnostics in place.
dispatch
    :: KnownTargetNames
    -> Maybe LoadedModule
    -> FilePath
    -> FileEvent
    -> Maybe DispatchAction
dispatch knownTargets known fp event = case known of
    Just lm -> Just $ case event of
        Added -> Reload
        Modified -> Reload
        Removed -> Unadd lm.moduleName
    Nothing
        | fileMatchesAnyTarget knownTargets fp -> case event of
            Added -> Just Reload
            Modified -> Just Reload
            Removed -> Nothing
        | otherwise -> case event of
            Added -> Just (Add fp)
            Modified -> Just (Add fp)
            Removed -> Nothing


-- | Keep only diagnostics whose file is under one of the watched directories.
--
-- Diagnostics from outside the project (e.g. @.h@ files in the Nix store) and
-- those with mangled filenames produced by the C preprocessor (e.g.
-- @"In file included from ..."@) are dropped here, before they can enter the
-- accumulation map where they would be impossible to evict.
--
-- Location-less diagnostics (see 'isLocationLess') are always kept: they carry
-- no path to test against a watch dir, but they represent genuine build-level
-- failures (e.g. a home-unit GHC plugin that can't load under
-- @--enable-multi-repl@) that must not be dropped, or the build would silently
-- read as clean.
filterToWatchDirs :: FilePath -> WatchDirs -> [Diagnostic] -> [Diagnostic]
filterToWatchDirs _ (WatchDirs []) diags = diags
filterToWatchDirs projectRoot (WatchDirs watchDirs) diags =
    filter (\d -> isLocationLess d.file || isUnderAnyWatchDir d.file) diags
  where
    absWatchDirs = map toAbsWd watchDirs
    toAbsWd wd
        | wd == "." = projectRoot
        | isAbsolute wd = wd
        | otherwise = projectRoot </> wd
    isUnderAnyWatchDir file
        | not (isAbsolute file) && not ("./" `isPrefixOf` file) = False
        | isAbsolute file =
            any (\wd -> (wd ++ "/") `isPrefixOf` file || wd == file) absWatchDirs
        | otherwise =
            let absFile = projectRoot </> drop 2 file
            in  any (\wd -> (wd ++ "/") `isPrefixOf` absFile || wd == absFile) absWatchDirs


-- | Keep a failed build from ever reading as clean after watch-dir filtering.
--
-- 'filterToWatchDirs' drops diagnostics outside the watched directories. If a
-- load failed but every error it produced lay outside those dirs (e.g. a
-- compile error in a sibling home unit not under @watchDirs@), filtering would
-- leave no diagnostics and the broken build would look green. Detecting that an
-- error was present /before/ filtering but none survived, we re-attach the
-- location-less synthetic failure (which filtering always keeps) so the failure
-- still surfaces.
--
-- Takes the pre-filter diagnostics and the post-filter diagnostics; returns the
-- 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]
    | otherwise = filtered
  where
    isError d = d.severity == SError