packages feed

auto-export-0.1.0.1: src/AutoExport.hs

{-# LANGUAGE TypeFamilies #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE CPP #-}
module AutoExport
  ( plugin
  ) where

import           Control.Exception
import           Control.Monad.IO.Class (liftIO)
import qualified Control.Monad.Writer.CPS as W
import qualified Data.ByteString as BS hiding (uncons)
import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Internal as BS
import qualified Data.Char as Char
import           Data.Maybe
import           Data.Monoid
import qualified Data.Set as Set
import           Data.String
import qualified GHC.Paths as Paths
import qualified Language.Haskell.GHC.ExactPrint as EP
import qualified Language.Haskell.GHC.ExactPrint.Parsers as EP
import qualified Language.Haskell.GHC.ExactPrint.Utils as EP

import qualified AutoExport.GhcFacade as Ghc

plugin :: Ghc.Plugin
plugin = Ghc.defaultPlugin
  { Ghc.driverPlugin = const modifyHscEnv
  , Ghc.pluginRecompile = mempty
  }

modifyHscEnv :: Ghc.HscEnv -> IO Ghc.HscEnv
modifyHscEnv hscEnv =
    pure hscEnv { Ghc.hsc_hooks = modifyHooks (Ghc.hsc_hooks hscEnv) }
  where
    isExportError msgEnv =
      case Ghc.errMsgDiagnostic msgEnv of
        Ghc.GhcPsMessage (Ghc.PsErrBangPatWithoutSpace (Ghc.L _ (Ghc.HsVar _ (Ghc.L _ rdr))))
          | "EXPORT" <- rdrNameFS rdr -> True
        _ -> False

    modifyHooks hooks =
      let runPhaseOrExistingHook :: Ghc.TPhase res -> IO res
          runPhaseOrExistingHook = maybe Ghc.runPhase (\(Ghc.PhaseHook h) -> h)
            $ Ghc.runPhaseHook hooks
          phaseHook :: Ghc.PhaseHook
          phaseHook = Ghc.PhaseHook $ \phase -> case phase of
            Ghc.T_Hsc env modSum -> catch (runPhaseOrExistingHook phase) $
              \ (Ghc.SourceError msgs) ->
                 case any isExportError $ Ghc.getMessages msgs of
                  True | Just updatedBuffer <- modifyBuffer =<< Ghc.ms_hspp_buf modSum
                    -> do
                    let innerPlugin = mkInnerPlugin hscEnv
                        updatedModSum = modSum { Ghc.ms_hspp_buf = Just updatedBuffer }
                        staticPlugin = Ghc.StaticPlugin
                          { Ghc.spPlugin = Ghc.PluginWithArgs innerPlugin []
#if MIN_VERSION_ghc(9,12,0)
                          , Ghc.spInitialised = True
#endif
                          }
                        newEnv = env
                          { Ghc.hsc_plugins = let plugins = Ghc.hsc_plugins hscEnv in plugins
                            { Ghc.staticPlugins = staticPlugin : Ghc.staticPlugins plugins }
                          }
                    runPhaseOrExistingHook (Ghc.T_Hsc newEnv updatedModSum)
                  _ -> throw $ Ghc.SourceError msgs
            _ -> runPhaseOrExistingHook phase
       in hooks
            { Ghc.runPhaseHook = Just phaseHook }

rdrNameFS :: Ghc.RdrName -> Ghc.FastString
rdrNameFS = Ghc.occNameFS . Ghc.rdrNameOcc

modifyBuffer :: Ghc.StringBuffer -> Maybe Ghc.StringBuffer
modifyBuffer = fmap Ghc.stringBufferFromByteString . rewriteRawExtract . stringBufferToBS

stringBufferToBS :: Ghc.StringBuffer -> BS.ByteString
stringBufferToBS Ghc.StringBuffer {Ghc.buf = buf, Ghc.len = len} =
  BS.BS buf len

rewriteRawExtract :: BS.ByteString -> Maybe BS.ByteString
rewriteRawExtract = toMaybe . W.runWriter . traverse update . BS.lines
  where
    update x | isExportCmd x = W.writer ("{-# ANN EXPORT () #-}", Any True)
             | otherwise = pure x
    toMaybe (x, Any True) = Just $ BS.unlines x
    toMaybe _ = Nothing

removeExportCmds :: BS.ByteString -> BS.ByteString
removeExportCmds = BS.unlines . filter (not . isExportCmd) . BS.lines

isExportCmd :: BS.ByteString -> Bool
isExportCmd = (== "!EXPORT") . BS.strip

mkInnerPlugin :: Ghc.HscEnv -> Ghc.Plugin
mkInnerPlugin hscEnv = Ghc.defaultPlugin
  { Ghc.parsedResultAction = \_ modSum result -> do
      let newExportItems =
            getExports . Ghc.hsmodDecls . Ghc.unLoc . Ghc.hpm_module
            $ Ghc.parsedResultModule result
      case Ghc.ml_hs_file (Ghc.ms_location modSum) of
        Nothing -> pure result
        Just filePath -> do
          liftIO $ prepareSourceForParsing filePath
          let dynFlags = Ghc.ms_hspp_opts modSum `Ghc.gopt_set` Ghc.Opt_KeepRawTokenStream
          parseResult <- liftIO $ parseModule hscEnv dynFlags filePath
          case parseResult of
            (Right parsedMod, usesCpp) ->
              liftIO $ modifyModule parsedMod usesCpp newExportItems filePath
            (Left _, _) -> pure ()

          let exportErr =
                let fn = fromMaybe "<UNKNOWN>" $ Ghc.ml_hs_file (Ghc.ms_location modSum)
                 in Ghc.mkPlainErrorMsgEnvelope
                      (Ghc.mkGeneralSrcSpan $ fromString fn)
                      (Ghc.ghcUnknownMessage ExportDiag)
          Ghc.throwOneError exportErr
  }

modifyModule
  :: Ghc.ParsedSource
  -> Bool
  -> [Ghc.IE Ghc.GhcPs]
  -> FilePath
  -> IO ()
modifyModule parsedMod usesCpp newIEs filePath = do
  putStrLn . Ghc.showSDocUnsafe $ Ghc.ppr newIEs
  let ast = EP.makeDeltaAst parsedMod
      addIEs m = m
          { Ghc.hsmodExports = fmap (addExports newIEs) <$> Ghc.hsmodExports m }
      updatedMod = addIEs <$> ast
  -- If the source contains CPP, newlines are appended
  -- to the end of the file when exact printing. The simple
  -- solution is to remove trailing newlines after exact printing
  -- if the source contains CPP comments.
  let removeTrailingNewlines
        | usesCpp =
            reverse . ('\n' :) . dropWhile (== '\n') . reverse
        | otherwise = id
      printed = removeTrailingNewlines $ EP.exactPrint updatedMod
  writeFile filePath printed

addExports :: [Ghc.IE Ghc.GhcPs] -> [Ghc.LIE Ghc.GhcPs] -> [Ghc.LIE Ghc.GhcPs]
addExports [] lies = lies
addExports newIEs lies = liesWithComma ++ newIEsWithCommas
  where
    existingNames = Set.fromList $
      (listToMaybe . Ghc.ieNames . Ghc.unLoc) `mapMaybe` lies
    iesToAdd = filter ((`Set.notMember` existingNames) . Ghc.ieName) newIEs
    addComma (Ghc.L l ie) = Ghc.L (EP.addComma l) ie
    liesWithComma = case reverse lies of
      l : ls -> reverse $ addComma l : ls
      [] -> []
    newIEsWithCommas = case reverse (Ghc.L Ghc.anchorD1 <$> iesToAdd) of
      l : ls -> reverse $ l : (addComma <$> ls)
      [] -> []

getExports :: [Ghc.LHsDecl Ghc.GhcPs] -> [Ghc.IE Ghc.GhcPs]
getExports (Ghc.L _ a : Ghc.L locB b : rest)
  | Ghc.AnnD _ (Ghc.HsAnnotation _ prov _) <- a
  , Ghc.ValueAnnProvenance (Ghc.L _ rdrName) <- prov
  , "EXPORT" <- rdrNameFS rdrName
  , ies <- mkIE b
  = ies ++ getExports (Ghc.L locB b : rest)
  | otherwise = getExports (Ghc.L locB b : rest)
getExports _ = []

mkIE :: Ghc.HsDecl Ghc.GhcPs -> [Ghc.IE Ghc.GhcPs]
mkIE = \case
    Ghc.TyClD _ tyCl ->
      let getTyName = Ghc.unLoc . Ghc.tyClDeclLName
       in case tyCl of
            _ | Ghc.isTypeFamilyDecl tyCl -> [mkThingAbsIE (getTyName tyCl)]
            _ | Ghc.isDataFamilyDecl tyCl -> [mkThingAllIE False (getTyName tyCl)]
            Ghc.SynDecl{} -> [mkThingAbsIE (getTyName tyCl)]
            _ -> [mkThingAllIE (isTypeData tyCl) (getTyName tyCl)]
    Ghc.ValD _ (Ghc.FunBind _ (Ghc.L _ name) _) -> [mkVarIE name]
    Ghc.ValD _ (Ghc.PatSynBind _ psb) -> [mkPatternIE (Ghc.unLoc $ Ghc.psb_id psb)]
    Ghc.SigD _ sig -> case sig of
      Ghc.TypeSig _ names _ -> mkVarIE . Ghc.unLoc <$> names
      Ghc.PatSynSig _ names _ -> mkPatternIE . Ghc.unLoc <$> names
      _ -> []
    Ghc.KindSigD _ (Ghc.StandaloneKindSig _ (Ghc.L _ name) _) -> [mkThingAbsIE name]
    Ghc.ForD _ for -> [mkVarIE . Ghc.unLoc $ Ghc.fd_name for]
    Ghc.InstD _ Ghc.DataFamInstD{Ghc.dfid_inst = inst} ->
      [mkThingAllIE False . Ghc.unLoc . Ghc.feqn_tycon $ Ghc.dfid_eqn inst]
    _ -> []
  where
    mkThingAllIE :: Bool -> Ghc.RdrName -> Ghc.IE Ghc.GhcPs
    mkThingAllIE isTyData name =
      Ghc.IEThingAll Ghc.ieThingAllAnn
        (Ghc.L Ghc.noSrcSpanA
          (if isOperator name || isTyData
           then
              Ghc.IEType Ghc.ieTypeAnn
                (addOpParens $ Ghc.L Ghc.anchorD1 name)
           else
              Ghc.IEName Ghc.noExtField
                (addOpParens $ Ghc.L Ghc.anchorD0 name)
          )
        )
#if MIN_VERSION_ghc(9,10,0)
        Nothing
#endif
    mkVarIE :: Ghc.RdrName -> Ghc.IE Ghc.GhcPs
    mkVarIE name = Ghc.ieVar
      (Ghc.L Ghc.noSrcSpanA
        (Ghc.IEName Ghc.noExtField . addOpParens $ Ghc.L Ghc.anchorD0 name)
      )
    mkPatternIE :: Ghc.RdrName -> Ghc.IE Ghc.GhcPs
    mkPatternIE name = Ghc.ieThingAbs
      (Ghc.L Ghc.noSrcSpanA
        (Ghc.IEPattern Ghc.iePatternAnn
          (addOpParens $ Ghc.L Ghc.anchorD1 name)
        )
      )
    mkThingAbsIE :: Ghc.RdrName -> Ghc.IE Ghc.GhcPs
    mkThingAbsIE name = Ghc.ieThingAbs
        (Ghc.L Ghc.noSrcSpanA
          (if isOperator name
           then
            Ghc.IEType Ghc.ieTypeAnn
              (addOpParens $ Ghc.L Ghc.anchorD1 name)
           else
            Ghc.IEName
              Ghc.noExtField
              (addOpParens $ Ghc.L Ghc.anchorD0 name)
          )
        )
    -- Adds parens for operators
    addOpParens :: Ghc.LIdP Ghc.GhcPs -> Ghc.LIdP Ghc.GhcPs
    addOpParens (Ghc.L loc name)
      | isOperator name =
          Ghc.L
#if MIN_VERSION_ghc(9,10,0)
            (loc { Ghc.anns = Ghc.nameAnnParens })
#else
            (loc { Ghc.ann = case Ghc.ann loc of
                     Ghc.EpAnnNotUsed -> Ghc.noAnn -- doesn't happen
                     a -> a { Ghc.anns = Ghc.nameAnnParens }
                 })
#endif
            name
      | otherwise = Ghc.L loc name

    isTypeData = \case
      Ghc.DataDecl { Ghc.tcdDataDefn = defn } ->
        Ghc.isTypeDataDefnCons $ Ghc.dd_cons defn
      _ -> False

isOperator :: Ghc.RdrName -> Bool
isOperator name =
  case BS.uncons (Ghc.bytesFS $ rdrNameFS name) of
    Nothing -> False
    Just (c, _) ->
      c /= '_' && not (Char.isAlpha c)

-- | Parse the given module file. Accounts for CPP comments
parseModule
  :: Ghc.HscEnv
  -> Ghc.DynFlags
  -> FilePath
  -> IO (EP.ParseResult Ghc.ParsedSource, Bool)
parseModule env dynFlags filePath = EP.ghcWrapper Paths.libdir $ do
  Ghc.setSession env { Ghc.hsc_dflags = dynFlags }
  res <- EP.parseModuleEpAnnsWithCppInternal EP.defaultCppOptions dynFlags filePath
  let eCppComments = fmap (\(c, _, _) -> c) res
      hasCpp = case eCppComments of
                 Right cs -> not $ null cs
                 _ -> False
  pure
    ( liftA2 EP.insertCppComments
        (EP.postParseTransform res)
        eCppComments
    , hasCpp
    )

-- | Diagnostic thrown when extraction occurs
data ExportDiag = ExportDiag

instance Ghc.Diagnostic ExportDiag where
  type DiagnosticOpts ExportDiag = Ghc.NoDiagnosticOpts
  diagnosticMessage _ _ = Ghc.mkSimpleDecorated $
    Ghc.text "Module updated by auto-export, compilation aborted"
  diagnosticReason _ = Ghc.ErrorWithoutFlag
  diagnosticHints _ = []
  diagnosticCode _ = Nothing
#if !MIN_VERSION_ghc(9,8,0)
  defaultDiagnosticOpts = Ghc.NoDiagnosticOpts
#endif


prepareSourceForParsing
  :: FilePath
  -> IO ()
prepareSourceForParsing filePath = do
  content <- BS.readFile filePath
  BS.writeFile filePath (removeExportCmds content)