packages feed

sydtest-mutation-runtime-0.1.0.0: src/Test/Syd/Mutation/Manifest/Render.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Colored text rendering for 'MutationRecord' / 'MutationManifest'.
--
-- Shared between the plugin's manifest-emission step (which writes a
-- @.txt@ next to each @.json@) and the test runner's surviving-mutation
-- report.  See 'renderMutationRecord' for the unit of rendering.
module Test.Syd.Mutation.Manifest.Render
  ( renderMutationRecord,
    renderManifest,
    writeManifestTxtFile,
    renderUnifiedDiff,

    -- * Colour helpers (also used by 'sydtest')
    delColour,
    addColour,
    emphasiseIntraLine,
    renderDelSide,
    renderAddSide,
  )
where

import Data.Maybe (mapMaybe)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Vector as V
import Myers.Diff (PolyDiff (..), getGroupedDiff, getTextDiff)
import Path
import Path.IO (ensureDir)
import qualified System.IO as IO
import Test.Syd.Mutation.Manifest (MutationGroup (..), MutationManifest (..), MutationRecord (..))
import Test.Syd.Mutation.Runtime (MutationId (..))
import Text.Colour

-- | Render a single 'MutationRecord' as a header line followed by either:
--
--   * a unified diff body, when the record carries 'mutRecSourceLines' /
--     'mutRecMutatedLines' / context, or
--   * a one-line @- original@ / @+ replacement@ fallback when source lines
--     are not available (e.g. mutations whose source span was
--     'UnhelpfulSpan' and so the plugin couldn't extract any line text).
--
-- The header is @<operator> at <file>:<line>:<colStart>-<colEnd>[ #variant]@,
-- matching the existing surviving-mutation report so reviewers see a
-- single consistent format whether they look at the runtime report or the
-- per-module manifest @.txt@.
renderMutationRecord :: MutationRecord -> [[Chunk]]
renderMutationRecord
  MutationRecord
    { mutRecId,
      mutRecOperator,
      mutRecOriginal,
      mutRecReplacement,
      mutRecModule,
      mutRecLine,
      mutRecColStart,
      mutRecColEnd,
      mutRecSourceFile,
      mutRecSourceLines,
      mutRecMutatedLines,
      mutRecContextBefore,
      mutRecContextAfter
    } =
    let MutationId parts = mutRecId
        filePath = case mutRecSourceFile of
          Just p -> fromRelFile p
          Nothing -> moduleToFilePath (T.unpack mutRecModule)
        -- The id's last component is the alternative index (the id is
        -- @[module, operator, line, colStart, colEnd, altIndex]@); show it as a
        -- @ #n@ suffix so several alternatives at one span are distinguishable.
        variantSuffix = case parts of
          [_, _, _, _, _, altIdx] -> " #" ++ altIdx
          _ -> ""
        headerText =
          T.pack $
            T.unpack mutRecOperator
              ++ " at "
              ++ filePath
              ++ ":"
              ++ show mutRecLine
              ++ ":"
              ++ show mutRecColStart
              ++ "-"
              ++ show mutRecColEnd
              ++ variantSuffix
        headerLine = [chunk headerText]
     in case mutRecSourceLines of
          [] ->
            [ headerLine,
              [fore red (chunk ("    - " <> mutRecOriginal))],
              [fore green (chunk ("    + " <> mutRecReplacement))]
            ]
          _ ->
            headerLine
              : renderUnifiedDiff
                (fromIntegral mutRecLine)
                mutRecContextBefore
                mutRecSourceLines
                mutRecMutatedLines
                mutRecContextAfter
    where
      moduleToFilePath m = map (\c -> if c == '.' then '/' else c) m ++ ".hs"

-- | Render every record in a manifest, one per group with a blank line in
-- between.  The leading argument is the module name, used for the
-- top-of-file header.
--
-- The header is followed by a count line ("@N mutations in M groups@") so
-- an empty manifest doesn't look like a rendering accident, and so
-- reviewers can sanity-check the totals at a glance.
--
-- Records appear in manifest order (which mirrors plugin discovery order);
-- the renderer makes no attempt to re-sort.
renderManifest :: String -> MutationManifest -> [[Chunk]]
renderManifest moduleName (MutationManifest groups) =
  let records = concatMap (\(MutationGroup rs) -> rs) groups
      nRecords = length records
      nGroups = length groups
      header = [fore cyan (chunk (T.pack ("# " ++ moduleName)))]
      countLine =
        [ chunk (T.pack (show nRecords ++ " " ++ pluralise nRecords "mutation" ++ " in " ++ show nGroups ++ " " ++ pluralise nGroups "group"))
        ]
      renderBlock r = [] : renderMutationRecord r
   in header : countLine : concatMap renderBlock records
  where
    pluralise n word = if n == 1 then word else word ++ "s"

-- | Write the rendered manifest to @<dir>/<moduleName>.txt@ with
-- 8-bit ANSI escapes embedded.  Reviewers can read with @cat foo.txt@ or
-- @less -R foo.txt@.
--
-- An empty manifest still produces a file (containing just the module
-- header) so the directory keeps a 1:1 correspondence between @.json@ and
-- @.txt@ entries.
writeManifestTxtFile :: Path Abs Dir -> String -> MutationManifest -> IO ()
writeManifestTxtFile dir moduleName manifest = do
  ensureDir dir
  fileName <- parseRelFile (moduleName ++ ".txt")
  let rendered = renderManifest moduleName manifest
      txt = renderChunksText With8BitColours (unlinesChunks rendered)
      path = fromAbsFile (dir </> fileName)
  IO.withFile path IO.WriteMode $ \h -> do
    IO.hSetBinaryMode h True
    IO.hPutStr h (T.unpack txt)

-- ---------------------------------------------------------------------------
-- Diff rendering (shared with sydtest's MutationMode.Common)

-- | Render a unified-diff hunk with intra-line colouring.  Used by both
-- 'renderMutationRecord' and the runtime mutation report.
--
-- @startLine@ is the 1-based source line of the first 'srcLines' entry;
-- the @@\@\@ -a,b +c,d \@\@@ header is computed from there and the lengths
-- of context, source, and mutated lines.
renderUnifiedDiff :: Int -> [Text] -> [Text] -> [Text] -> [Text] -> [[Chunk]]
renderUnifiedDiff startLine ctxBefore srcLines mutLines ctxAfter =
  let allBefore = ctxBefore ++ srcLines ++ ctxAfter
      allAfter = ctxBefore ++ mutLines ++ ctxAfter
      groups = getGroupedDiff allBefore allAfter
      hunkStart = startLine - length ctxBefore
      origCount = length allBefore
      mutCount = length allAfter
      hunkHeader =
        T.pack $
          "@@ -"
            ++ show hunkStart
            ++ ","
            ++ show origCount
            ++ " +"
            ++ show hunkStart
            ++ ","
            ++ show mutCount
            ++ " @@"
   in [fore cyan (chunk hunkHeader)] : renderGroups groups
  where
    renderGroups :: [PolyDiff [Text] [Text]] -> [[Chunk]]
    renderGroups [] = []
    renderGroups (Both ls _ : rest) =
      map (\l -> [chunk (T.cons ' ' l)]) ls ++ renderGroups rest
    renderGroups gs@(First _ : _) =
      let (dels, adds, rest) = collectChange gs
       in renderPaired dels adds ++ renderGroups rest
    renderGroups gs@(Second _ : _) =
      let (dels, adds, rest) = collectChange gs
       in renderPaired dels adds ++ renderGroups rest

    collectChange :: [PolyDiff [Text] [Text]] -> ([Text], [Text], [PolyDiff [Text] [Text]])
    collectChange = go [] []
      where
        go ds as (First ls : rest) = go (ds ++ ls) as rest
        go ds as (Second ls : rest) = go ds (as ++ ls) rest
        go ds as rest = (ds, as, rest)

    renderDelLine :: Text -> [Chunk]
    renderDelLine l = [fore delColour (chunk (T.cons '-' l))]

    renderAddLine :: Text -> [Chunk]
    renderAddLine l = [fore addColour (chunk (T.cons '+' l))]

    renderPaired :: [Text] -> [Text] -> [[Chunk]]
    renderPaired dels adds =
      let n = min (length dels) (length adds)
          (pairedDels, extraDels) = splitAt n dels
          (pairedAdds, extraAdds) = splitAt n adds
          (delLines, addLines) = unzip (zipWith renderPair pairedDels pairedAdds)
       in delLines ++ map renderDelLine extraDels ++ addLines ++ map renderAddLine extraAdds

    renderPair :: Text -> Text -> ([Chunk], [Chunk])
    renderPair delLine addLine =
      let charDiff = V.toList (getTextDiff delLine addLine)
          delChunks = fore delColour (chunk (T.singleton '-')) : renderDelSide charDiff
          addChunks = fore addColour (chunk (T.singleton '+')) : renderAddSide charDiff
       in (delChunks, addChunks)

-- ---------------------------------------------------------------------------
-- Colour helpers (formerly in sydtest's Test.Syd.Output.Common; moved here
-- so the runtime can render manifest diffs without depending on sydtest).

-- | Foreground colour used on deletion lines (the "@-@" side of a diff).
-- Plain 'red' rather than a 256-colour shade so each terminal applies its
-- own palette.
delColour :: Colour
delColour = red

-- | Foreground colour used on addition lines (the "@+@" side).
addColour :: Colour
addColour = green

-- | Emphasise an intra-line changed substring.  Non-whitespace text gets
-- bold + the brighter shade of the side's colour, so it stands out within
-- a line that is otherwise foreground-coloured with the dull shade.
-- Whitespace-only text has no glyph to colour, so fill the background
-- instead.
emphasiseIntraLine :: Colour -> Colour -> Text -> Chunk
emphasiseIntraLine lineCol brightCol t =
  if T.null (T.strip t)
    then back lineCol (chunk t)
    else bold (fore brightCol (chunk t))

-- | Render the deletion side of a character-level diff.  'Both' chars get
-- 'delColour' as their whole-line foreground; this-side changes ('First')
-- get 'emphasiseIntraLine'd with 'brightRed'.  Addition-only chunks
-- ('Second') are dropped — they belong to the other side's line.
renderDelSide :: [PolyDiff Text Text] -> [Chunk]
renderDelSide =
  mapMaybe $ \case
    First t -> Just (emphasiseIntraLine delColour brightRed t)
    Second _ -> Nothing
    Both t _ -> Just (fore delColour (chunk t))

-- | Symmetric counterpart of 'renderDelSide' for the addition side.
renderAddSide :: [PolyDiff Text Text] -> [Chunk]
renderAddSide =
  mapMaybe $ \case
    First _ -> Nothing
    Second t -> Just (emphasiseIntraLine addColour brightGreen t)
    Both t _ -> Just (fore addColour (chunk t))