packages feed

sydtest-mutation-plugin-0.4.4.0: src/Test/Syd/Mutation/Plugin/Operator/RemoveClause.hs

{-# LANGUAGE LambdaCase #-}

module Test.Syd.Mutation.Plugin.Operator.RemoveClause (theOperator) where

import GHC
import GHC.Builtin.Types (boolTy, falseDataCon, trueDataCon)
import Test.Syd.Mutation.Plugin.Instrument
  ( ClauseAlt (..),
    InstrM,
    MutationOperator (..),
    MutationOperatorKind (..),
    SrcSpanDelta (..),
    wrapWithIfMutation,
  )
import Test.Syd.Mutation.Runtime (MutationId)

-- | Remove one clause (equation) from a function binding with two or more
-- clauses.
--
-- A clause is a 'Match', not an 'LHsExpr', so this cannot be expressed through
-- the expression-level @ifMutation \@ty mutant original@ swap.  Instead, the
-- clause to be removed gets a guard @ifMutation mid False True@ prepended to
-- every one of its 'GRHS's: when @mid@ is the active mutation every guard
-- evaluates to 'False', so the clause matches no input and control falls
-- through to the next equation (or, for the last matching clause, to a
-- non-exhaustive @MatchFail@ that any covering test kills immediately).  When
-- @mid@ is inactive the guard is 'True' and 'ifMutation' also performs the
-- usual coverage bookkeeping, so no new runtime primitive is needed.
--
-- Because the injected guard sits after the clause's patterns, it is only
-- evaluated when those patterns match — exactly the inputs for which removing
-- the clause could change behaviour, so coverage is attributed to the tests
-- that actually reach the clause.
--
-- Only fires on bindings with at least two clauses: removing the sole clause of
-- a function is always a non-exhaustive crash, a trivially-killed mutant that
-- would only add noise.
theOperator :: MutationOperator
theOperator =
  MutationOperator
    { operatorName = "RemoveClause",
      operatorDescription = "Remove one clause (equation) from a function binding",
      operatorKind = FunctionDeclarationOperator $ \case
        MG _ (L _ alts)
          | length alts >= 2 ->
              Just (pure (zipWith (mkAlt (length alts)) [0 ..] alts))
        _ -> Nothing
    }

-- | Build the removal alternative for clause @i@ (0-based) of @n@.
mkAlt :: Int -> Int -> LMatch GhcTc (LHsExpr GhcTc) -> ClauseAlt
mkAlt n i alt =
  ClauseAlt
    { clauseAltSpan = getLocA alt,
      clauseAltOriginal = show n ++ " clauses",
      clauseAltReplacement = show (n - 1) ++ " clauses (removed #" ++ show (i + 1) ++ ")",
      clauseAltDelta = SpanRemoval removedSpan,
      clauseAltMitigation = Nothing,
      clauseAltApply = injectRemovalGuard i
    }
  where
    removedSpan = case getLocA alt of
      RealSrcSpan rss _ -> [rss]
      UnhelpfulSpan _ -> []

-- | Prepend @ifMutation mid False True@ as a guard to every 'GRHS' of clause
-- @i@, so that whole clause is skipped when @mid@ is the active mutation.
injectRemovalGuard ::
  Int ->
  MutationId ->
  MatchGroup GhcTc (LHsExpr GhcTc) ->
  InstrM (MatchGroup GhcTc (LHsExpr GhcTc))
injectRemovalGuard i mid = \case
  MG x (L lann alts) -> do
    guardStmt <- mkRemovalGuardStmt mid
    let alts' = [if j == i then prependGuard guardStmt alt else alt | (j, alt) <- zip [0 ..] alts]
    pure (MG x (L lann alts'))

-- | Prepend a guard statement to every 'GRHS' of a clause.
prependGuard ::
  GuardLStmt GhcTc ->
  LMatch GhcTc (LHsExpr GhcTc) ->
  LMatch GhcTc (LHsExpr GhcTc)
prependGuard guardStmt = fmap $ \case
  Match mx ctx pats (GRHSs gx grhss localBinds) ->
    Match mx ctx pats (GRHSs gx (map (fmap addGuard) grhss) localBinds)
  where
    addGuard :: GRHS GhcTc (LHsExpr GhcTc) -> GRHS GhcTc (LHsExpr GhcTc)
    addGuard = \case
      GRHS gx guards body -> GRHS gx (guardStmt : guards) body

-- | The boolean guard statement @ifMutation mid False True@.
mkRemovalGuardStmt :: MutationId -> InstrM (GuardLStmt GhcTc)
mkRemovalGuardStmt mid = do
  guardExpr <- wrapWithIfMutation boolTy mid (nlHsDataCon falseDataCon) (nlHsDataCon trueDataCon)
  pure (noLocA (BodyStmt boolTy guardExpr noSyntaxExpr noSyntaxExpr))