auto-import-0.1.0.0: src/AutoImport.hs
{-# LANGUAGE CPP #-}
{-# LANGUAGE TypeFamilies #-}
module AutoImport
( plugin
) where
import Control.Exception (try, throw)
import Data.Containers.ListUtils (nubOrd)
import Data.Either (partitionEithers)
import Data.Foldable
import qualified Data.List as List
import qualified Data.List.NonEmpty as NE
import qualified Data.Map as M
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified GHC.LanguageExtensions as LangExt
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 AutoImport.Config
import qualified AutoImport.GhcFacade as Ghc
--------------------------------------------------------------------------------
-- Plugin
--------------------------------------------------------------------------------
plugin :: Ghc.Plugin
plugin = Ghc.defaultPlugin
{ Ghc.driverPlugin = \args hscEnv -> pure $ addHscHook args hscEnv
, Ghc.pluginRecompile = Ghc.purePlugin
}
addHscHook :: [Ghc.CommandLineOption] -> Ghc.HscEnv -> Ghc.HscEnv
addHscHook args hscEnv = hscEnv
{ Ghc.hsc_hooks =
let hooks = Ghc.hsc_hooks hscEnv
in hooks
{ Ghc.runPhaseHook = Just $ phaseHook (Ghc.runPhaseHook hooks) }
}
where
phaseHook mExistingHook = Ghc.PhaseHook $ \phase -> case phase of
Ghc.T_Hsc env modSum -> do
let modFile = Ghc.ms_hspp_file modSum
importsAddedErr =
Ghc.mkPlainErrorMsgEnvelope
(Ghc.mkGeneralSrcSpan $ Ghc.mkFastString modFile)
(Ghc.ghcUnknownMessage ImportsAddedDiag)
eTcRes <- try $ runPhaseOrExistingHook phase
autoImportCfg <- resolveConfig (asum $ parseConfigPathArg <$> args)
let msgs = case eTcRes of
Left (Ghc.SourceError m) -> m
Right (_, m) -> m
missingThings =
foldMap (missingThingFromMsg autoImportCfg) (Ghc.getMessages msgs)
case NE.nonEmpty (nubOrd $ toList missingThings) of
Just neMissing -> do
-- Parse from file because the parse result from GHC lacks comments
let dynFlags = Ghc.ms_hspp_opts modSum `Ghc.gopt_set` Ghc.Opt_KeepRawTokenStream
(eParseResult, usesCpp) <- parseModule env dynFlags modFile
case eParseResult of
Left errs -> throw $ Ghc.mkSrcErr errs
Right parseResult -> do
modifyModule parseResult usesCpp neMissing modFile
throw . Ghc.mkSrcErr . Ghc.mkMessages $ Ghc.unitBag importsAddedErr
_ -> either throw pure eTcRes
_ -> runPhaseOrExistingHook phase
where
runPhaseOrExistingHook :: Ghc.TPhase res -> IO res
runPhaseOrExistingHook = maybe Ghc.runPhase (\(Ghc.PhaseHook h) -> h) mExistingHook
parseConfigPathArg :: Ghc.CommandLineOption -> Maybe FilePath
parseConfigPathArg ('-':'-':'i':'m':'p':'o':'r':'t':'-':'c':'f':'g':'=':path) = Just path
parseConfigPathArg _ = Nothing
moduleNameToText :: Ghc.ModuleName -> T.Text
moduleNameToText = TE.decodeUtf8 . Ghc.bytesFS . Ghc.moduleNameFS
data MissingThing
= MissingModule QualMod
| MissingId UnqualIdentifier
deriving (Eq, Ord)
missingThingFromMsg :: Config -> Ghc.MsgEnvelope Ghc.GhcMessage -> [MissingThing]
missingThingFromMsg autoImportCfg msgEnv =
case Ghc.errMsgDiagnostic msgEnv of
Ghc.GhcTcRnMessage
(Ghc.TcRnMessageWithInfo _
(Ghc.TcRnMessageDetailed _
(Ghc.TcRnNotInScope Ghc.NotInScope _ [Ghc.MissingModule missingMod] _))
)
| let modTxt = moduleNameToText missingMod
, Just qualMods <- M.lookup modTxt (unMonoidMap $ qualModules autoImportCfg)
-> MissingModule <$> qualMods
Ghc.GhcTcRnMessage
(Ghc.TcRnMessageWithInfo _
(Ghc.TcRnMessageDetailed _
(Ghc.TcRnNotInScope Ghc.NotInScope rdrName [] hints))
)
| Just unqualId <- M.lookup (T.pack $ EP.rdrName2String rdrName) (unqualIdentifiers autoImportCfg)
, let isDataKindExtHint = \case
Ghc.SuggestExtension (Ghc.SuggestSingleExtension _ LangExt.DataKinds) -> True
_ -> False
-- If -XDataKinds is suggested, that means the import already exists
-- and should not be added again.
, not (any isDataKindExtHint hints)
-> [MissingId unqualId]
Ghc.GhcTcRnMessage
(Ghc.TcRnMessageWithInfo _
(Ghc.TcRnMessageDetailed _
(Ghc.TcRnSolverReport' report )
))
| Ghc.ReportHoleError hole _
<- Ghc.reportContent report
, Just unqualId <- M.lookup (T.pack . EP.rdrName2String $ Ghc.hole_occ hole) (unqualIdentifiers autoImportCfg)
-> [MissingId unqualId]
_ -> []
-- | Diagnostic thrown when import statements are inserted
data ImportsAddedDiag = ImportsAddedDiag
instance Ghc.Diagnostic ImportsAddedDiag where
type DiagnosticOpts ImportsAddedDiag = Ghc.NoDiagnosticOpts
diagnosticMessage _ _ = Ghc.mkSimpleDecorated $
Ghc.text "Module updated by auto-import, compilation aborted"
diagnosticReason _ = Ghc.ErrorWithoutFlag
diagnosticHints _ = []
diagnosticCode _ = Nothing
#if !MIN_VERSION_ghc(9,8,0)
defaultDiagnosticOpts = Ghc.NoDiagnosticOpts
#endif
--------------------------------------------------------------------------------
-- Modify source
--------------------------------------------------------------------------------
-- | Parse the given module file. Accounts for CPP comments
parseModule
:: Ghc.HscEnv
-> Ghc.DynFlags
-> String
-> 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
)
modifyModule
:: Ghc.ParsedSource
-> Bool
-> NE.NonEmpty MissingThing
-> FilePath
-> IO ()
modifyModule parsedMod usesCpp neMissing filePath = do
let updatedAst = modifyAST neMissing parsedMod
-- 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.
removeTrailingNewlines
| usesCpp =
reverse . ('\n' :) . dropWhile (== '\n') . reverse
| otherwise = id
printed = removeTrailingNewlines $ EP.exactPrint updatedAst
writeFile filePath printed
modifyAST :: NE.NonEmpty MissingThing -> Ghc.ParsedSource -> Ghc.ParsedSource
modifyAST missingThings parsedSource =
let
Ghc.L modLoc hsMod = EP.makeDeltaAst parsedSource
(qualMods, unqualIds) = partitionEithers $
NE.toList missingThings >>= \case
MissingModule m -> [Left m]
MissingId i -> [Right (importByMod i, [i])]
newQualImports = mkQualImport <$>
zip (null updatedImports : repeat False)
qualMods
(newUnqualIds, updatedImports) =
addIdsToExistingImports
(M.fromListWith (<>) unqualIds)
(Ghc.hsmodImports hsMod)
newUnqualImports = mkUnqualImport <$>
zip ((null updatedImports && null newQualImports) : repeat False)
(M.toList newUnqualIds)
importSrcSpan isFirst =
let lineDelta = if isFirst then 2 else 1
in Ghc.noAnnSrcSpanDP' $ Ghc.DifferentLine lineDelta 0
mkQualImport (isFirst, qualMod) =
let mn = Ghc.mkModuleName $ T.unpack (modName qualMod)
in Ghc.L (importSrcSpan isFirst)
(Ghc.simpleImportDecl mn)
{ Ghc.ideclQualified = Ghc.QualifiedPre
, Ghc.ideclAs = Ghc.L (Ghc.noAnnSrcSpanDP' $ Ghc.SameLine 1)
. Ghc.mkModuleName . T.unpack <$> modQual qualMod
, Ghc.ideclName = Ghc.L (Ghc.noAnnSrcSpanDP' $ Ghc.SameLine 1) mn
, Ghc.ideclExt = Ghc.XImportDeclPass importEpAnnQual Ghc.NoSourceText False
}
mkUnqualImport :: (Bool, (T.Text, [UnqualIdentifier])) -> Ghc.LImportDecl Ghc.GhcPs
mkUnqualImport (isFirst, (moName, ids)) =
let mn = Ghc.mkModuleName $ T.unpack moName
impList = addCommas $ uncurry mkIE <$>
zip
(True : repeat False)
(associateUnqualIds ids)
in Ghc.L (importSrcSpan isFirst)
(Ghc.simpleImportDecl mn)
{ Ghc.ideclName = Ghc.L (Ghc.noAnnSrcSpanDP' $ Ghc.SameLine 1) mn
, Ghc.ideclExt = Ghc.XImportDeclPass Ghc.importEpAnn Ghc.NoSourceText False
, Ghc.ideclImportList = Just (Ghc.Exactly, Ghc.L Ghc.importListAnn impList)
}
importEpAnnQual = (Ghc.noAnn :: Ghc.EpAnn Ghc.EpAnnImportDecl)
#if MIN_VERSION_ghc(9,12,0)
{ Ghc.anns = Ghc.noAnn
{ Ghc.importDeclAnnImport = Ghc.EpTok Ghc.noAnn
, Ghc.importDeclAnnQualified = Just (Ghc.EpTok EP.d1)
, Ghc.importDeclAnnAs = Just (Ghc.EpTok EP.d1)
}
}
#endif
in Ghc.L modLoc hsMod
{ Ghc.hsmodImports = updatedImports ++ newQualImports ++ newUnqualImports
}
addIdsToExistingImports
:: M.Map T.Text [UnqualIdentifier]
-> [Ghc.LImportDecl Ghc.GhcPs]
-> (M.Map T.Text [UnqualIdentifier], [Ghc.LImportDecl Ghc.GhcPs])
addIdsToExistingImports = List.mapAccumR go
where
go iMap (Ghc.L iLoc idec)
| Ghc.NotQualified <- Ghc.ideclQualified idec
, let hsModName = moduleNameToText . Ghc.unLoc $ Ghc.ideclName idec
, Just ids <- M.lookup hsModName iMap
, Just (Ghc.Exactly, Ghc.L idsLoc existingIEs) <- Ghc.ideclImportList idec
= let (idsWithParent, idsWithoutParent) = partitionEithers $ do
i <- ids
case parentTy i of
Nothing -> [Right i]
Just parent -> [Left (idLabel parent, [i])]
idsWithParentMap = M.fromListWith (<>) idsWithParent
(idsWithParentToAdd, updatedExistingIEs) =
addIdsToExistingIEs idsWithParentMap existingIEs
idsWithoutParentDeduped =
filter (\x -> M.notMember (identifier x) idsWithParentMap) idsWithoutParent
newIEs = uncurry mkIE <$>
zip
(null updatedExistingIEs : repeat False)
(associateUnqualIds (idsWithoutParentDeduped ++ fold idsWithParentToAdd))
updatedImportList = (Ghc.Exactly, Ghc.L idsLoc . addCommas $ updatedExistingIEs ++ newIEs)
newDecl = idec { Ghc.ideclImportList = Just updatedImportList }
in (M.delete hsModName iMap, Ghc.L iLoc newDecl)
go iMap idecl = (iMap, idecl)
associateUnqualIds :: [UnqualIdentifier] -> [(IdInfo, [IdInfo])]
associateUnqualIds ids = M.toList . M.fromListWith (<>) $ do
i <- ids
case parentTy i of
Nothing -> [(toIdInfo i, [])]
Just pt -> [(pt, [toIdInfo i])]
mkIE :: Bool -> (IdInfo, [IdInfo]) -> Ghc.LIE Ghc.GhcPs
mkIE isFirstItem (parentId, children) = Ghc.L (ieLoc isFirstItem) $
case children of
[] -> Ghc.IEVar' (mkIEWrappedName True parentId)
_ -> Ghc.IEThingWith' Ghc.ieThingWithAnn
(mkIEWrappedName True parentId)
Ghc.NoIEWildcard
(addCommas $ uncurry mkIEWrappedName <$> zip (True : repeat False) children)
where
ieLoc isFirst = Ghc.noAnnSrcSpanDP' $ Ghc.SameLine (if isFirst then 0 else 1)
mkIEWrappedName :: Bool -> IdInfo -> Ghc.LIEWrappedName Ghc.GhcPs
mkIEWrappedName isFirst i =
Ghc.L ieLoc . ieCon . txtToRdrName $ idLabel i
where
ieLoc = Ghc.noAnnSrcSpanDP' $ Ghc.SameLine (if isFirst then 0 else 1)
ieCon = case idNamespace i of
Just PatternNS -> Ghc.IEPattern Ghc.epTokD0 . Ghc.L (Ghc.nameAnn (idIsOp i) True)
Just TypeNS -> Ghc.IEType Ghc.epTokD0 . Ghc.L (Ghc.nameAnn (idIsOp i) True)
Nothing -> Ghc.IEName Ghc.noExtField . Ghc.L (Ghc.nameAnn (idIsOp i) False)
addCommas :: [Ghc.GenLocated Ghc.SrcSpanAnnA e] -> [Ghc.GenLocated Ghc.SrcSpanAnnA e]
addCommas [] = []
addCommas [x] = [x]
addCommas (Ghc.L ann x : xs) =
Ghc.L (if Ghc.hasTrailingComma ann then ann else EP.addComma ann) x
: addCommas xs
addIdsToExistingIEs
:: M.Map T.Text [UnqualIdentifier] -- keyed by parent ty name
-> [Ghc.LIE Ghc.GhcPs]
-> (M.Map T.Text [UnqualIdentifier], [Ghc.LIE Ghc.GhcPs])
addIdsToExistingIEs = List.mapAccumR go
where
go idMap (Ghc.L ieLoc (Ghc.IEThingAbs' name))
| Ghc.IEName _ (Ghc.L _ rdrName) <- Ghc.unLoc name
, let nameTxt = T.pack $ EP.rdrName2String rdrName
, Just ids <- M.lookup nameTxt idMap
, let newChildren =
uncurry mkIEWrappedName . fmap toIdInfo <$>
zip (True : repeat False) ids
newLie = Ghc.L ieLoc
(Ghc.IEThingWith' Ghc.ieThingWithAnn name Ghc.NoIEWildcard (addCommas newChildren))
= (M.delete nameTxt idMap, newLie)
go idMap (Ghc.L ieLoc (Ghc.IEThingWith' x name wc children))
| Ghc.IEName _ (Ghc.L _ rdrName) <- Ghc.unLoc name
, let nameTxt = T.pack $ EP.rdrName2String rdrName
, Just ids <- M.lookup nameTxt idMap
, let newChildren =
uncurry mkIEWrappedName . fmap toIdInfo <$>
zip (null children : repeat False) ids
newLie = Ghc.L ieLoc (Ghc.IEThingWith' x name wc (addCommas $ children ++ newChildren))
= (M.delete nameTxt idMap, newLie)
go idMap lie = (idMap, lie)
txtToRdrName :: T.Text -> Ghc.RdrName
txtToRdrName = Ghc.mkRdrUnqual . Ghc.mkVarOcc . T.unpack
toIdInfo :: UnqualIdentifier -> IdInfo
toIdInfo i = IdInfo
{ idLabel = identifier i
, idIsOp = isOperator i
, idNamespace = namespace i
}