packages feed

tricorder-0.6.0.0: src/Tricorder/Daemon/Ghci/Result.hs

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