packages feed

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

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

module Test.Syd.Mutation.Plugin.Operator.Util
  ( opOccName,
    mkOpReplacement,
    mkIntLitExpr,
    TcOpApp (..),
    matchTcOpApp,
    ConstFnMatch (..),
    viewConstFnResult,
    mkNothingExpr,
    mkConstLambda,
    arrowTy,
    prefixFormPreview,
    unwrapWrap,
    collectApp,
    headFunctionName,
    nameMatchCandidates,
    headNameMatches,
    spanText,
  )
where

import Control.Monad.IO.Class (liftIO)
import Data.List (nub)
import Data.Text (Text)
import qualified Data.Text as T
import GHC
import GHC.Builtin.Types (manyDataConTy, nothingDataCon)
import GHC.Core.DataCon (dataConWrapId)
import GHC.Core.TyCo.Rep (Scaled (..))
import GHC.Core.Type (isForAllTy, mkVisFunTyMany, splitFunTy_maybe, splitTyConApp_maybe)
import GHC.Data.Bag (bagToList)
import GHC.Hs.Syn.Type (lhsExprType)
import GHC.Tc.Types (TcM)
import GHC.Tc.Utils.Env (tcLookupId)
import GHC.Types.Basic (DoPmc (..), GenReason (..), Origin (..))
import GHC.Types.Id (idName)
import GHC.Types.Name (getOccString, nameModule_maybe)
import GHC.Types.Name.Occurrence (lookupOccEnv, mkVarOcc)
import GHC.Types.Name.Reader (GlobalRdrEnv, greName, gre_imp, importSpecModule, lookupGRE_Name)
import GHC.Types.SourceText (mkIntegralLit)
import GHC.Utils.Outputable (ppr)
import GHC.Utils.Panic (pprPanic)

-- | The 'Name' at the head of an application chain.  At GhcTc the head often
-- has type and dictionary arguments attached (e.g. @(+) \@Int $dNumInt@), so we
-- walk through 'HsApp', 'HsAppType', and 'WrapExpr' wrappers to find the
-- underlying 'HsVar'.
--
-- Also peels through @XExpr (ExpandedThingTc orig expanded)@: GHC 9.6+ expands
-- source-level @f $ x@ and similar rebindable-syntax forms into @HsApp@ chains
-- wrapped in 'ExpandedThingTc'.  Without this case, a call like @logInfo $ ...@
-- would look like a "no head" expression, and the ignore filter (which
-- dispatches on this head) would let mutations leak into the argument subtree.
--
-- Treats @($)@ specially: at GhcTc, @f $ x@ is expanded into the application
-- chain @($) f x@, but the syntactic "head" the user thinks of is @f@, not
-- @($)@.  Whenever we encounter @($) f x@ (or any further nesting of @$@s),
-- we recurse on the first argument instead of returning @($)@.
opName :: LHsExpr GhcTc -> Maybe Name
opName = \case
  L _ (HsVar _ (L _ v)) -> Just (idName v)
  L _ (XExpr (WrapExpr (HsWrap _ e))) -> opName (noLocA e)
  L _ (XExpr (ExpandedThingTc _ e)) -> opName (noLocA e)
  L _ (HsApp _ f a)
    | Just n <- opName f, getOccString n == "$" -> opName a
    | otherwise -> opName f
  L _ (HsAppType _ f _) -> opName f
  L _ (HsPar _ e) -> opName e
  _ -> Nothing

-- | The unqualified @OccName@ string of the head returned by 'opName', if any.
-- Used where only the bare operator/identifier name matters, e.g. the infix
-- operator extraction in 'matchTcOpApp' and the @(>>)@ check in 'RemoveAction'.
opOccName :: LHsExpr GhcTc -> Maybe String
opOccName = fmap getOccString . opName

-- | Does the head of @le@ (as found by 'opName') match any of the given
-- names?  Each name matches either the bare @OccName@ (e.g. @mark@, matching
-- any module) or a fully-qualified @Module.name@ form — by the defining module
-- or by an import module — via 'nameMatchCandidates'.  Used by the @ignore@
-- filter and by 'RemoveAction'.
--
-- The empty-list case short-circuits before touching the GlobalRdrEnv: the
-- @ignore@ filter calls this on every expression in the module, so when no
-- names are configured (the common case) we avoid a per-expression GRE lookup.
headNameMatches :: GlobalRdrEnv -> [String] -> LHsExpr GhcTc -> Bool
headNameMatches _ [] _ = False
headNameMatches rdrEnv names le =
  any ((`elem` names) . T.unpack) (maybe [] (nameMatchCandidates rdrEnv) (opName le))

-- | A view of a binary infix operator application as it appears in the
-- typechecked AST.  At GhcTc, GHC always expands the source-level @OpApp@ into
-- @HsApp (HsApp op lhs) rhs@ wrapped in @XExpr (ExpandedThingTc (OrigExpr orig)
-- expanded)@.  Operators that want to match infix uses (Arith, Cmp, LogicOp)
-- should use 'matchTcOpApp' rather than pattern-matching on @OpApp@ directly,
-- which would always fail.
data TcOpApp = TcOpApp
  { -- | The result type of the operator application.
    tcOpAppTy :: Type,
    -- | The left operand (typechecked).
    tcOpAppLhs :: LHsExpr GhcTc,
    -- | The operator expression (typechecked, with type and dictionary args
    -- still attached).  Used as the substitution target for
    -- 'mkOpReplacement'.
    tcOpAppOp :: LHsExpr GhcTc,
    -- | The right operand (typechecked).
    tcOpAppRhs :: LHsExpr GhcTc,
    -- | The operator's @OccName@ string (e.g. \"+\", \"==\").
    tcOpAppOcc :: String,
    -- | The source 'RealSrcSpan' of the operator token (as written by the
    -- user), if available.  Recovered from the renamed @OrigExpr@ inside
    -- @ExpandedThingTc@; will be @Nothing@ if the operator originated outside
    -- a syntactic infix application.
    tcOpAppOpSrcSpan :: Maybe RealSrcSpan
  }

-- | Match a typechecked binary infix operator application.  Recognises both
-- the post-typecheck expanded form (@ExpandedThingTc@ wrapping an @HsApp@
-- chain) and a raw @OpApp@ as a fallback.
matchTcOpApp :: LHsExpr GhcTc -> Maybe TcOpApp
matchTcOpApp le = case unLoc le of
  -- Post-typecheck expanded form: @ExpandedThingTc (OrigExpr (OpApp _ l op r))
  --                                                (HsApp _ (HsApp _ tcOp tcL) tcR)@.
  XExpr (ExpandedThingTc orig expanded)
    | Just (tcOp, tcL, tcR) <- viewHsApp2 expanded,
      Just occ <- opOccName tcOp ->
        Just (TcOpApp (lhsExprType tcL) tcL tcOp tcR occ (origOpSrcSpan orig))
  -- Fallback for any compiler version that keeps OpApp at GhcTc.
  OpApp _ lhs op rhs
    | Just occ <- opOccName op ->
        Just (TcOpApp (lhsExprType lhs) lhs op rhs occ (rnOpSrcSpan op))
  _ -> Nothing

-- | Get the source 'RealSrcSpan' of the operator from the renamed origin of
-- an @ExpandedThingTc@ wrapper, if it was an infix application.
origOpSrcSpan :: HsThingRn -> Maybe RealSrcSpan
origOpSrcSpan = \case
  OrigExpr (OpApp _ _ op _) -> rnOpSrcSpan op
  _ -> Nothing

-- | Get the 'RealSrcSpan' of an operator expression (renamed or typechecked).
rnOpSrcSpan :: GenLocated SrcSpanAnnA e -> Maybe RealSrcSpan
rnOpSrcSpan (L l _) = case locA l of
  RealSrcSpan rss _ -> Just rss
  UnhelpfulSpan _ -> Nothing

-- | Decompose a 2-argument application: @HsApp _ (HsApp _ f a) b@ → @(f, a, b)@.
-- Skips through 'WrapExpr' wrappers.
viewHsApp2 :: HsExpr GhcTc -> Maybe (LHsExpr GhcTc, LHsExpr GhcTc, LHsExpr GhcTc)
viewHsApp2 e0 = case unwrapWrap e0 of
  HsApp _ outer rhs -> case unwrapWrap (unLoc outer) of
    HsApp _ f lhs -> Just (f, lhs, rhs)
    _ -> Nothing
  _ -> Nothing

-- | Skip outermost 'WrapExpr' layers.
unwrapWrap :: HsExpr GhcTc -> HsExpr GhcTc
unwrapWrap = \case
  XExpr (WrapExpr (HsWrap _ inner)) -> unwrapWrap inner
  e -> e

-- | Build the mutated infix application by substituting the operator id.
--
-- @origOp@ is the typechecked operator expression (an @HsVar@ wrapped in type
-- and dictionary applications).  We replace the innermost 'HsVar' id with the
-- replacement operator's id, then reconstruct the application as
-- @HsApp _ (HsApp _ origOp[op := replOp] lhs) rhs@.
mkOpReplacement ::
  GlobalRdrEnv ->
  LHsExpr GhcTc ->
  LHsExpr GhcTc ->
  LHsExpr GhcTc ->
  String ->
  TcM (LHsExpr GhcTc)
mkOpReplacement rdrEnv l origOp r replOccStr = do
  replId <- case lookupOccEnv rdrEnv (mkVarOcc replOccStr) of
    Just (gre : _) -> tcLookupId (greName gre)
    _ ->
      liftIO $
        ioError $
          userError $
            "mutation: replacement operator " ++ replOccStr ++ " not in scope"
  let replOp = substOpId replId origOp
      inner = mkHsApp replOp l
      outer = mkHsApp inner r
  pure outer
  where
    -- Replace the innermost HsVar id in the op expression, preserving the
    -- surrounding 'HsApp', 'HsAppType', 'HsPar', and 'WrapExpr' wrappers that
    -- carry the typechecker's type and dictionary applications. Mirrors the
    -- structure 'opOccName' walks: substOpId must reach the same 'HsVar' that
    -- 'opOccName' would have returned the OccName for.
    substOpId :: Id -> LHsExpr GhcTc -> LHsExpr GhcTc
    substOpId newId = \case
      L ann (HsVar x (L l' _)) -> L ann (HsVar x (L l' newId))
      L ann (XExpr (WrapExpr (HsWrap w e))) ->
        L ann (XExpr (WrapExpr (HsWrap w (unLoc (substOpId newId (noLocA e))))))
      L ann (HsApp x f a) -> L ann (HsApp x (substOpId newId f) a)
      L ann (HsAppType x f t) -> L ann (HsAppType x (substOpId newId f) t)
      L ann (HsPar x e) -> L ann (HsPar x (substOpId newId e))
      other -> other

-- | Build a replacement integer literal expression without any validation.
mkIntLitExpr :: Integer -> OverLitTc -> LHsExpr GhcTc
mkIntLitExpr n oltc =
  let iln = mkIntegralLit n
      witnessN = substIntegerInWitness n (ol_witness oltc)
      oltcN = oltc {ol_witness = witnessN}
   in noLocA (HsOverLit NoExtField (OverLit oltcN (HsIntegral iln)))

-- | Substitute the integer value in a @fromInteger dict (HsInteger _ n _)@ witness.
substIntegerInWitness :: Integer -> HsExpr GhcTc -> HsExpr GhcTc
substIntegerInWitness n = \case
  HsApp x f arg -> HsApp x f (fmap (substIntegerInWitness n) arg)
  HsLit x (HsInteger src _ ty) -> HsLit x (HsInteger src n ty)
  XExpr (WrapExpr (HsWrap w e)) -> XExpr (WrapExpr (HsWrap w (substIntegerInWitness n e)))
  e -> e

-- | Result of matching an expression for the @Const…@ family of operators:
-- peel arrows on @le@'s type until the head TyCon matches a target, with
-- the argument types of the arrows that were peeled.
data ConstFnMatch = ConstFnMatch
  { -- | Argument types of the arrows that were peeled, in order.  Empty for
    -- arity 0 (the expression itself already has the target type).
    cfnArgTys :: [Type],
    -- | The result type (the target TyCon applied to its arguments).
    cfnResTy :: Type,
    -- | The result type's TyCon arguments, e.g. @[a]@ for @[a] -> Maybe a@.
    cfnTyConArgs :: [Type]
  }

-- | If @le@'s type matches @arg1 -> ... -> argN -> targetTyCon tcArgs@ with
-- @N >= minArity@, return the peeled arrow argument types, the final result
-- type, and the target TyCon's arguments.  Used by the @Const…@ family.
--
-- Returns 'Nothing' when:
--
--   * @le@'s outermost head is a data constructor (e.g. @Just x@, @x : xs@) —
--     those have their own dedicated operators ('MaybeOp', 'ListLit') and
--     would duplicate them,
--   * the expression's type has a forall or class constraint (we can't
--     synthesise a constant under one without building a typed dictionary or
--     type lambda),
--   * after peeling arrows, the result type does not split as
--     @targetTyCon args@,
--   * the arity (number of arrows peeled) is less than @minArity@.
viewConstFnResult :: Int -> TyCon -> LHsExpr GhcTc -> Maybe ConstFnMatch
viewConstFnResult minArity targetTyCon le = do
  () <- nonConstructorHead le
  let ty = lhsExprType le
  if isForAllTy ty
    then Nothing
    else do
      let (argTys, resTy) = peelArrows ty
      if length argTys < minArity
        then Nothing
        else do
          (tc, tcArgs) <- splitTyConApp_maybe resTy
          if tc == targetTyCon
            then Just (ConstFnMatch argTys resTy tcArgs)
            else Nothing

-- | Build a @Nothing@ expression of the given (monomorphic) @Maybe elemTy@
-- type, with its type variable instantiated to @elemTy@.
--
-- 'nlHsDataCon nothingDataCon' on its own is @Nothing :: forall a. Maybe a@:
-- the un-instantiated @forall@ makes the @ifMutation \@ty@ wrapper the
-- framework builds around it ill-typed (its argument is expected at @Maybe
-- elemTy@ but is @forall a. Maybe a@).  GHC's Core Lint rejects that; with
-- Core Lint off the desugared program miscompiles the whole instrumented site
-- and segfaults at runtime (entering a null closure via @stg_ap_0_fast@).
-- Applying the element type keeps the Core well-typed — mirroring how
-- 'ConstEmptyList' threads its element type through @ExplicitList elTy []@.
mkNothingExpr :: Type -> LHsExpr GhcTc
mkNothingExpr mayTy = case splitTyConApp_maybe mayTy of
  Just (_, [elemTy]) -> nlHsTyApp (dataConWrapId nothingDataCon) [elemTy]
  -- Callers only ever pass a saturated @Maybe elemTy@ (MaybeOp matched a
  -- @Just e@; ConstNothing already checked the result tycon is @Maybe@).  Fail
  -- loudly on any other type rather than silently emitting the un-instantiated
  -- @Nothing :: forall a. Maybe a@, whose ill-typed Core is the very bug this
  -- helper exists to avoid.
  _ -> pprPanic "mkNothingExpr: expected a saturated 'Maybe elemTy' type" (ppr mayTy)

-- | Peel as many @arg -> rest@ arrows as possible from the type, returning
-- the argument types and the final result type.  Stops at any
-- non-arrow type (including constrained types).
peelArrows :: Type -> ([Type], Type)
peelArrows = go []
  where
    go acc ty = case splitFunTy_maybe ty of
      Just (_af, _mult, argTy, resTy) -> go (argTy : acc) resTy
      Nothing -> (reverse acc, ty)

-- | Reject expressions whose outermost head is a data constructor at GhcTc
-- ('ConLikeTc' / a 'HsConLikeOut'-style node).  Peels through 'HsApp',
-- 'HsAppType', 'HsPar', 'WrapExpr', and 'ExpandedThingTc' wrappers.
nonConstructorHead :: LHsExpr GhcTc -> Maybe ()
nonConstructorHead = go
  where
    go h = case unLoc h of
      XExpr (ConLikeTc {}) -> Nothing
      HsApp _ f _ -> go f
      HsAppType _ f _ -> go f
      HsPar _ e -> go e
      XExpr (WrapExpr (HsWrap _ e)) -> go (L (getLoc h) e)
      XExpr (ExpandedThingTc _ e) -> go (L (getLoc h) e)
      _ -> Just ()

-- | Build @\\_ _ ... _ -> v@ at GhcTc.  @v@ must already have type @resTy@;
-- the resulting lambda has type @arg1 -> ... -> argN -> resTy@.
--
-- For arity 0 (empty @argTys@) this is the identity on @v@.
--
-- Used by the @Const…@ family of operators to produce a constant-function
-- mutant without depending on @Prelude.const@ being in scope.  All
-- annotation/origin fields are filled with neutral defaults so the desugarer
-- accepts the result and the pattern-match checker does not complain about
-- the wildcards.
mkConstLambda ::
  -- | Argument types, in source order: @[arg1, arg2, ..., argN]@.
  [Type] ->
  -- | Result type of @v@.
  Type ->
  -- | The bare value to wrap.
  LHsExpr GhcTc ->
  LHsExpr GhcTc
mkConstLambda [] _ body = body
mkConstLambda argTys resTy body =
  let pats = map (\ty -> noLocA (WildPat ty)) argTys
      grhs = noLocA (GRHS noAnn [] body)
      grhss = GRHSs emptyComments [grhs] (EmptyLocalBinds NoExtField)
      ctxt = LamAlt LamSingle
      match = noLocA (Match [] ctxt pats grhss)
      mgtc =
        MatchGroupTc
          { mg_arg_tys = map (Scaled manyDataConTy) argTys,
            mg_res_ty = resTy,
            mg_origin = Generated OtherExpansion SkipPmc
          }
      mg = MG mgtc (noLocA [match])
   in noLocA (HsLam [] LamSingle mg)

-- | Build an arrow type @arg1 -> ... -> argN -> resTy@ with default (Many)
-- multiplicity.
arrowTy :: [Type] -> Type -> Type
arrowTy as resTy = foldr mkVisFunTyMany resTy as

-- | Build a prefix-form preview text for an arity-N constant-function
-- mutation that sits at an infix operator position.
--
-- Given operand source text and a mutant rendering, produces text that
-- replaces the whole enclosing @OpApp@ source span in prefix form:
--
--   arity 2:  @(\\_ _ -> v) (lhsText) (rhsText)@
--   arity 1:  @(\\_ -> v) (rhsText)@           — the partial app
--                                              consumed the LHS already.
--
-- The result reparses as a normal Haskell expression and has the same
-- runtime semantics as the AST mutation, so the manifest diff is honest.
prefixFormPreview ::
  -- | Arity of the constant function being inserted.
  Int ->
  -- | Source text of the constant body (e.g. @"True"@, @"Nothing"@, @"[]"@).
  Text ->
  -- | Source text of the LHS operand of the infix @OpApp@.
  Text ->
  -- | Source text of the RHS operand of the infix @OpApp@.
  Text ->
  Text
prefixFormPreview arity vText lhsText rhsText =
  let lam =
        "(\\"
          <> T.replicate arity "_ "
          <> "-> "
          <> vText
          <> ")"
   in case arity of
        2 -> lam <> " (" <> lhsText <> ") (" <> rhsText <> ")"
        1 -> lam <> " (" <> rhsText <> ")"
        _ -> lam

-- | The value arguments of a prefix application, in source order, together
-- with the function at the head.
--
-- The head is returned intact, retaining the type- and dictionary-application
-- wrappers the typechecker attached to it, so reapplying it to (possibly
-- mutated) arguments stays well-typed.
--
-- We deliberately do /not/ peel an enclosing 'HsPar': the parenthesis node
-- @(f a b)@ and the inner application @f a b@ are visited as separate
-- expressions by the walker (which recurses into an 'HsPar' with
-- 'instrumentLExpr', re-running every operator).  Peeling here would make an
-- operator fire on both nodes.  Stopping at the 'HsPar' means only the inner
-- application produces the mutation.
collectApp :: LHsExpr GhcTc -> (LHsExpr GhcTc, [LHsExpr GhcTc])
collectApp = go []
  where
    go acc le = case unLoc le of
      HsApp _ f a -> go (a : acc) f
      _ -> (le, acc)

-- | The 'Name' of the function (or constructor) at the head of an
-- application, peeling the type- and dictionary-application wrappers the
-- typechecker leaves around the 'HsVar'.  'Nothing' for heads that are not a
-- plain variable (e.g. a lambda or a data constructor at 'ConLikeTc').
headFunctionName :: LHsExpr GhcTc -> Maybe Name
headFunctionName = go
  where
    go le = case unLoc le of
      HsVar _ (L _ v) -> Just (idName v)
      XExpr (WrapExpr (HsWrap _ e)) -> go (noLocA e)
      HsAppType _ f _ -> go f
      HsPar _ e -> go e
      _ -> Nothing

-- | The strings a @skip-calls-to@ or @ignore@ entry may use to refer to a
-- name: the bare occurrence (@union@), the fully-qualified form using the
-- name's /defining/ module (@Data.Set.Internal.union@), and the
-- fully-qualified forms using each module the name is /imported through/ at
-- this use site (@Data.Set.union@).  Including the import modules lets a user
-- qualify a re-exported function by the module they actually import it from,
-- not only by the internal module it happens to be defined in.
nameMatchCandidates :: GlobalRdrEnv -> Name -> [Text]
nameMatchCandidates rdrEnv n =
  let occ = T.pack (getOccString n)
      definingMods = maybe [] (\m -> [moduleName m]) (nameModule_maybe n)
      importMods = case lookupGRE_Name rdrEnv n of
        Just gre -> map importSpecModule (bagToList (gre_imp gre))
        Nothing -> []
      qualify m = T.pack (moduleNameString m) <> "." <> occ
   in occ : map qualify (nub (definingMods ++ importMods))

-- | The exact source text covered by a 'RealSrcSpan'.  Multi-line spans are
-- joined with single spaces, which is enough for the no-op comparison and the
-- manifest summary.
spanText :: [Text] -> RealSrcSpan -> Text
spanText allLines rss =
  let startLine = srcSpanStartLine rss
      endLine = srcSpanEndLine rss
      colS = srcSpanStartCol rss
      colE = srcSpanEndCol rss
      lineN i = case drop (i - 1) allLines of
        (l : _) -> l
        [] -> T.empty
   in if startLine == endLine
        then T.take (colE - colS) (T.drop (colS - 1) (lineN startLine))
        else
          let firstPart = T.drop (colS - 1) (lineN startLine)
              middleParts = map lineN [startLine + 1 .. endLine - 1]
              lastPart = T.take (colE - 1) (lineN endLine)
           in T.intercalate " " (firstPart : middleParts ++ [lastPart])