packages feed

skeletest-0.4.0: src/Skeletest/Internal/PreprocessorPlugin.hs

{-# LANGUAGE CPP #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TemplateHaskellQuotes #-}
{-# LANGUAGE ViewPatterns #-}

module Skeletest.Internal.PreprocessorPlugin (
  plugin,
) where

import Data.Functor.Const (Const (..))
import Data.Maybe (fromMaybe, listToMaybe)
import Data.Text qualified as Text
import Skeletest.Internal.Constants (mainFileSpecsListIdentifier)
import Skeletest.Internal.Error (skeletestPluginError)
import Skeletest.Internal.GHC
import Skeletest.Internal.Paths (setOriginalDirectory)
import Skeletest.Internal.Predicate qualified as P
import Skeletest.Internal.Preprocessor qualified as Preprocessor
import Skeletest.Internal.Utils.HList (HList (..))
import Skeletest.Internal.Utils.Text (showT)
import Skeletest.Main qualified as Main
import Skeletest.Plugin qualified as Plugin
import Skeletest.Prop.Internal qualified as P

#if !MIN_VERSION_base(4, 20, 0)
import Data.Foldable (foldl')
#endif

-- | The plugin to convert a module in the tests directory.
-- Injected by the preprocessor.
plugin :: Plugin
plugin =
  mkPlugin
    PluginDef
      { isPure = True
      , modifyParsed = \opts modName modl ->
          let options = decodeOptions opts
           in if modName == options.mainModuleName
                then transformMainModule options modl
                else modl
      , onRename = \_ ctx modName expr ->
          if "Spec" `Text.isSuffixOf` modName
            then transformTestModule ctx expr
            else expr
      }
 where
  decodeOptions =
    either (error . Text.unpack) id . \case
      [opts] -> Preprocessor.decodeOptions (Text.pack opts)
      _ -> Left ""

-- | Add 'main' function.
transformMainModule :: Preprocessor.Options -> ParsedModule -> ParsedModule
transformMainModule options modl =
  modl
    { moduleFuncs = (hsVarName options.mainFuncName, Just mainFun) : modl.moduleFuncs
    }
 where
  findVar name =
    fmap hsExprVar . listToMaybe $
      [ funName
      | (funName, _) <- modl.moduleFuncs
      , getHsName funName == name
      ]

  cliFlagsExpr = fromMaybe (hsExprList []) $ findVar "cliFlags"
  snapshotRenderersExpr = fromMaybe (hsExprList []) $ findVar "snapshotRenderers"
  hooksExpr = fromMaybe (hsExprVar $ hsName 'Plugin.defaultHooks) $ findVar "hooks"
  pluginsExpr = fromMaybe (hsExprList []) $ findVar "plugins"

  mainFun =
    FunDef
      { funType = HsTypeApps (HsTypeCon $ hsName ''IO) [HsTypeTuple []]
      , funPats = []
      , funBody =
          sequenceExpr
            [ setOriginalDirectoryExpr
            , runSkeletestExpr
            ]
      }
  sequenceExpr exprs = hsExprApps (hsExprVar $ hsName 'sequence_) [hsExprList exprs]
  setOriginalDirectoryExpr =
    hsExprApps (hsExprVar $ hsName 'setOriginalDirectory) $
      [hsExprLitString . Text.pack $ options.originalDirectory]
  runSkeletestExpr =
    hsExprApps
      (hsExprVar $ hsName 'Main.runSkeletest)
      [ hsExprApps (hsExprVar (hsName '(:))) $
          [ hsExprRecordCon
              (hsName 'Plugin.Plugin)
              [ (hsFieldName 'Plugin.Plugin "cliFlags", cliFlagsExpr)
              , (hsFieldName 'Plugin.Plugin "snapshotRenderers", snapshotRenderersExpr)
              , (hsFieldName 'Plugin.Plugin "hooks", hooksExpr)
              ]
          , pluginsExpr
          ]
      , hsExprVar $ hsVarName mainFileSpecsListIdentifier
      ]

transformTestModule :: Ctx -> HsExpr GhcRn -> HsExpr GhcRn
transformTestModule ctx =
  foldl' (.) id $
    [ replaceConMatch ctx
    , replaceIsoChecker ctx
    ]

-- | Replace all uses of P.con with P.conMatches. See P.con.
--
-- P.con $ User (P.eq "user1") (P.contains "@")
-- ====>
-- P.conMatches
--   "User"
--   Nothing
--   ( \case
--       User x0 x1 -> Just (HCons (pure x0) $ HCons (pure x1) $ HNil)
--       _ -> Nothing
--   )
--   (HCons (P.eq "user1") $ HCons (P.contains "@") $ HNil)
--
-- P.con User{name = P.eq "user1", email = P.contains "@"}
-- ====>
-- P.conMatches
--   "User"
--   (Just (HCons (Const "user") $ HCons (Const "email") $ HNil))
--   ( \case
--       User{name, email} -> Just (HCons (pure name) $ HCons (pure email) $ HNil)
--       _ -> Nothing
--   )
--   (HCons (P.eq "user1") $ HCons (P.contains "@") $ HNil)
replaceConMatch :: Ctx -> HsExpr GhcRn -> HsExpr GhcRn
replaceConMatch ctx e =
  case getExpr e of
    -- Matches:
    --   P.con User{name = ...}
    --   P.con (User "...")
    HsExprApps (getExpr -> HsExprVar name) [arg]
      | isCon name ->
          convertCon arg
    -- Matches:
    --   P.con $ User "..."
    HsExprOp (getExpr -> HsExprVar name) (getExpr -> HsExprVar dollar) arg
      | ctx.matchesName (hsName '($)) dollar
      , isCon name ->
          convertCon arg
    -- Check if P.con is by itself
    HsExprVar name
      | isCon name ->
          skeletestPluginError (getLoc e) "P.con must be applied to a constructor"
    -- Check if P.con is being applied more than once
    HsExprApps (getExpr -> HsExprVar name) (_ : _ : _)
      | isCon name ->
          skeletestPluginError (getLoc e) "P.con must be applied to exactly one argument"
    _ -> e
 where
  isCon = ctx.matchesName (hsName 'P.con)

  convertCon con =
    case getExpr con of
      HsExprCon conName -> convertPrefixCon conName []
      HsExprApps (getExpr -> HsExprCon conName) preds -> convertPrefixCon conName preds
      HsExprRecordCon conName fields -> convertRecordCon conName fields
      _ -> skeletestPluginError (getLoc e) "P.con must be applied to a constructor"
  convertPrefixCon conName preds =
    let
      exprNames = mkVarNames preds
     in
      hsExprApps (hsExprVar $ hsName 'P.conMatches) $
        [ hsExprLitString $ getHsName conName
        , hsExprCon $ hsName 'Nothing
        , mkDeconstruct (HsPatCon conName $ map HsPatVar exprNames) exprNames
        , mkPredList preds
        ]
  convertRecordCon conName fields =
    let
      (fieldNames, preds) = unzip fields
      fieldPats = [(field, HsPatVar field) | field <- fieldNames]
     in
      hsExprApps (hsExprVar $ hsName 'P.conMatches) $
        [ hsExprLitString $ getHsName conName
        , hsExprApps (hsExprCon $ hsName 'Just) [mkNamesList fieldNames]
        , mkDeconstruct (HsPatRecord conName fieldPats) fieldNames
        , mkPredList preds
        ]

  -- Generate variable names like x0, x1, ... for each element in the given list.
  mkVarNames =
    let mkVar i = "x" <> showT i
     in zipWith (\i _ -> hsVarName (mkVar i)) [0 :: Int ..]

  -- Create the deconstruction function:
  --
  -- \actual ->
  --   case actual of
  --     User{name} -> Just (HCons (pure name) HNil)
  --     _ -> Nothing
  --
  -- However, if 'User' is the only constructor, GHC complains about the wildcard
  -- being redundant. So we'll obfuscate it a bit with
  --
  -- \actual ->
  --   case pure actual of
  --     Just User{name} -> Just (HCons (pure name) HNil)
  --     _ -> Nothing
  mkDeconstruct pat argNames =
    hsExprLam [HsPatVar $ hsVarName "actual"] $
      hsExprCase (hsExprApps (hsExprVar $ hsName 'pure) [hsExprVar $ hsVarName "actual"]) $
        [ (HsPatCon (hsName 'Just) [pat], hsExprApps (hsExprCon $ hsName 'Just) [mkValsList argNames])
        , (HsPatWild, hsExprCon $ hsName 'Nothing)
        ]

  mkHList f = \case
    [] -> hsExprCon (hsName 'HNil)
    x : xs ->
      hsExprApps (hsExprCon $ hsName 'HCons) $
        [ f x
        , mkHList f xs
        ]

  mkNamesList = mkHList $ \name -> hsExprApps (hsExprCon $ hsName 'Const) [hsExprLitString $ getHsName name]
  mkValsList = mkHList $ \val -> hsExprApps (hsExprVar $ hsName 'pure) [hsExprVar val]
  mkPredList = mkHList id

-- | Replace all uses of P.=== with inlined IsoChecker value, with
-- function name filled in.
--
-- (encode . decode) P.=== id
-- ====>
-- IsoChecker (Fun "encode . decode" (encode . decode)) (Fun "id" id)
replaceIsoChecker :: Ctx -> HsExpr GhcRn -> HsExpr GhcRn
replaceIsoChecker ctx e =
  case getExpr e of
    HsExprOp l (getExpr -> HsExprVar eqeqeq) r
      | ctx.matchesName (hsName '(P.===)) eqeqeq ->
          inlineIsoChecker l r
    _ -> e
 where
  inlineIsoChecker l r = hsExprApps (hsExprCon $ hsName 'P.IsoChecker) [mkFun l, mkFun r]
  mkFun f = hsExprApps (hsExprCon $ hsName 'P.Fun) [hsExprLitString $ renderHsExpr f, f]