{-# LANGUAGE DeriveAnyClass #-} {-# LANGUAGE DeriveGeneric #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeFamilies #-} -- | Haskell Imports and Exports tool -- -- This tool parses imports and exports from Haskell source files and provides -- analysis on these imports. For example, you can check whether consistent -- import aliases are used across your codebase. module Main (main) where import qualified Data.Aeson as JSON import qualified Data.ByteString.Lazy.Char8 as LBS8 import qualified Data.Csv as Csv import qualified Data.Map as Map import qualified Data.Set as Set import qualified Data.Text as T import qualified Data.Text.IO as T import qualified Dot import qualified GHC import qualified GHC.Paths import qualified Language.Haskell.GHC.ExactPrint.Parsers as ExactPrint import qualified Options.Applicative as O import qualified System.FilePath as FP import Data.Aeson.Encode.Pretty (encodePretty) import Data.Function ((&)) import Data.List (intercalate) import Data.Maybe (catMaybes, mapMaybe) import Data.Text (Text) import GHC.Generics (Generic) import GHC.Hs.Extension (GhcPs) import GHC.Types.Error (getMessages) import GHC.Types.Name.Occurrence (occNameString) import GHC.Types.Name.Reader (rdrNameOcc) import GHC.Unit.Module.Name (moduleNameString) import GHC.Utils.Error (pprMsgEnvelopeBagWithLoc) import System.Directory.Recursive (getFilesRecursive) import System.Exit (exitFailure) -- TYPES data Options = Options { command :: Command , sources :: [FilePath] } data Command = Dump OutputFormat | GraphSymbols | GraphModules | CheckAliases | CheckWildcards [Text] data OutputFormat = OutputCsv | OutputJson data ImportedSymbol = ImportedSymbol { impFromModule :: Text , impModule :: Text , impQualified :: ImportQualified , impAlias :: Maybe Text , impType :: ImportType , impSymbol :: Maybe Text , impInternal :: ModuleInternal , impSource :: FilePath , impFile :: FilePath } deriving (Generic, Csv.ToNamedRecord, Csv.DefaultOrdered, JSON.ToJSON) data ImportQualified = Qualified | NotQualified deriving (Eq, Generic, JSON.ToJSON) instance Csv.ToField ImportQualified where toField Qualified = "qualified" toField NotQualified = "not qualified" data ModuleInternal = Internal | External deriving (Eq, Generic, JSON.ToJSON) instance Csv.ToField ModuleInternal where toField Internal = "internal" toField External = "external" data ImportType = Wildcard | Hiding | Explicit deriving (Eq, Generic, JSON.ToJSON) instance Csv.ToField ImportType where toField Wildcard = "wildcard" toField Hiding = "hiding" toField Explicit = "explicit" -- | Mapping of modules to their aliases and to the files they are found in type ModuleAliases = [(Text, [(Text, [FilePath])])] -- | Mapping of modules to files type WildcardImports = [(FilePath, [Text])] -- MAIN main :: IO () main = run =<< O.customExecParser prefs infoOpts where prefs = O.prefs $ O.subparserInline <> O.showHelpOnEmpty infoOpts = O.info (O.helper <*> opts) $ O.fullDesc <> O.header "hsie - Swiss army knife for HaSkell Imports and Exports" <> O.progDesc "Parse Haskell code to analyze imports and exports" opts = Options <$> commandOption <*> O.some srcOption srcOption = O.argument O.str $ O.metavar "SRCDIR" <> O.help "Haskell source directory" <> O.action "directory" commandOption = O.subparser $ command "dump-imports" "Dump imported symbols as CSV or JSON" (Dump <$> jsonOutputFlag) <> command "graph-modules" "Print dot graph of module imports" (pure GraphModules) <> command "graph-symbols" "Print dot graph of symbol imports" (pure GraphSymbols) <> command "check-aliases" "Check that aliases of imported modules are consistent" (pure CheckAliases) <> command "check-wildcards" "Check that no modules are imported as unqualified wildcards" (CheckWildcards <$> O.many okModuleOption) command name desc options = O.command name . O.info (O.helper <*> options) $ O.progDesc desc jsonOutputFlag = O.flag OutputCsv OutputJson $ O.long "json" <> O.short 'j' <> O.help "Output JSON" okModuleOption = O.strOption $ O.long "ok" <> O.short 'o' <> O.metavar "OKMODULE" <> O.help "Module that is ok to import as unqualified wildcard" run :: Options -> IO () run Options{command, sources} = runCommand command . markInternal . concat =<< mapM sourceSymbols sources where runCommand :: Command -> [ImportedSymbol] -> IO () runCommand (Dump format) = LBS8.putStr . dump format runCommand GraphSymbols = T.putStr . symbolsGraph runCommand GraphModules = T.putStr . Dot.encode . modulesGraph runCommand CheckAliases = runInconsistentAliases . inconsistentAliases runCommand (CheckWildcards okModules) = runWildcards . wildcards okModules runInconsistentAliases :: ModuleAliases -> IO () runInconsistentAliases [] = T.putStrLn "No inconsistent module aliases found." runInconsistentAliases xs = T.putStr (formatInconsistentAliases xs) >> exitFailure runWildcards :: WildcardImports -> IO () runWildcards [] = T.putStrLn "No unwanted wildcard imports found." runWildcards xs = T.putStr (formatWildcards xs) >> exitFailure -- | Mark imports from modules that are among the analyzed ones as internal. markInternal :: [ImportedSymbol] -> [ImportedSymbol] markInternal symbols = fmap mark symbols where mark s = s { impInternal = if isInternal s then Internal else External } isInternal = flip Set.member internalModules . impModule internalModules = Set.fromList $ fmap impFromModule symbols -- SYMBOLS -- | Parse all imported symbols from a source of Haskell source files sourceSymbols :: FilePath -> IO [ImportedSymbol] sourceSymbols source = do files <- filterExts [".hs", ".imports"] <$> getFilesRecursive source concat <$> mapM moduleSymbols files where filterExts exts = filter $ flip elem exts . FP.takeExtension moduleSymbols filepath = do GHC.HsModule{..} <- parseModule filepath return $ concatMap (importSymbols source filepath . GHC.unLoc) hsmodImports -- | Parse a Haskell module parseModule :: FilePath -> IO GHC.HsModule parseModule filepath = do result <- ExactPrint.parseModule GHC.Paths.libdir filepath case result of Right hsmod -> return $ GHC.unLoc hsmod Left errs -> fail $ "Errors with " <> show filepath <> ":\n " <> show (pprMsgEnvelopeBagWithLoc $ getMessages errs) -- | Symbols imported in an import declaration. -- -- If the import is a wildcard, i.e. no symbols are selected for import, then -- only one item is returned. importSymbols :: FilePath -> FilePath -> GHC.ImportDecl GhcPs -> [ImportedSymbol] importSymbols source filepath GHC.ImportDecl{..} = case ideclHiding of Just (hiding, syms) -> symbol (if hiding then Hiding else Explicit) . Just . GHC.unLoc <$> GHC.unLoc syms Nothing -> [ symbol Wildcard Nothing ] where symbol hiding sym = ImportedSymbol { impFile = relativePath filepath , impSource = source , impFromModule = T.pack $ moduleFromPath filepath , impModule = T.pack . moduleNameString . GHC.unLoc $ ideclName , impQualified = if ideclQualified /= GHC.NotQualified then Qualified else NotQualified , impAlias = T.pack . moduleNameString . GHC.unLoc <$> ideclAs , impInternal = External , impType = hiding , impSymbol = T.pack . occNameString . rdrNameOcc . GHC.ieName <$> sym } moduleFromPath = intercalate "." . FP.splitDirectories . FP.dropExtension . relativePath relativePath = FP.makeRelative source -- DUMP -- | Dump list of symbols as CSV or JSON dump :: OutputFormat -> [ImportedSymbol] -> LBS8.ByteString dump OutputCsv = Csv.encodeDefaultOrderedByName dump OutputJson = encodePretty -- ALIASES -- | Find modules that are imported under different aliases inconsistentAliases :: [ImportedSymbol] -> ModuleAliases inconsistentAliases symbols = foldr (insertSetMapMap . moduleAlias) Map.empty symbols & Map.map (aliases . Map.toList) & Map.filter ((<) 1 . length) & Map.toList where moduleAlias ImportedSymbol{..} = (impModule, impAlias, FP.joinPath [impSource, impFile]) insertSetMapMap (k1, k2, v) = Map.insertWith (Map.unionWith Set.union) k1 (Map.singleton k2 $ Set.singleton v) aliases :: [(Maybe Text, Set.Set FilePath)] -> [(Text, [FilePath])] aliases = mapMaybe (\(k, v) -> fmap (, Set.toList v) k) formatInconsistentAliases :: ModuleAliases -> Text formatInconsistentAliases modules = "The following imports have inconsistent aliases:\n\n" <> T.concat (fmap formatModule modules) where formatModule (modName, aliases) = "Module '" <> modName <> "' has the aliases:\n" <> T.concat (fmap formatAlias aliases) <> "\n" formatAlias (alias, sourceFiles) = " '" <> alias <> "' in file" <> (if length sourceFiles > 2 then "s" else "") <> ":\n" <> T.concat (fmap formatFile sourceFiles) formatFile sourceFile = " " <> T.pack sourceFile <> "\n" -- WILDCARDS -- | Find modules that are imported as wildcards, excluding whitelisted modules. -- -- Wildcard imports are ones that are not qualified and do not specify which -- symbols should be imported. wildcards :: [Text] -> [ImportedSymbol] -> WildcardImports wildcards okModules = groupByFile . filter isWildcard . filter (not . isOkModule) where isWildcard ImportedSymbol{..} = impQualified == NotQualified && impType /= Explicit isOkModule = flip Set.member (Set.fromList okModules) . impModule groupByFile = Map.toList . fmap Set.toList . foldr insertMap Map.empty insertMap ImportedSymbol{..} = Map.insertWith Set.union impFile (Set.singleton impModule) formatWildcards :: WildcardImports -> Text formatWildcards files = "Modules in the following files were imported as wildcards:\n\n" <> T.concat (fmap formatFile files) where formatFile (filepath, modules) = "In " <> T.pack filepath <> ":\n" <> T.concat (fmap formatModule modules) <> "\n" formatModule moduleName = " " <> moduleName <> "\n" -- GRAPHS modulesGraph :: [ImportedSymbol] -> Dot.DotGraph modulesGraph symbols = Dot.DotGraph Dot.Strict Dot.Directed (Just "Modules") $ fmap edge edges where edge (from, to) = Dot.StatementEdge $ Dot.EdgeStatement (Dot.ListTwo (edgeNode from) (edgeNode to) mempty) mempty edgeNode t = Dot.EdgeNode $ Dot.NodeId (Dot.Id t) Nothing edges = unique . fmap edgeTuple . filter ((==) Internal . impInternal) $ symbols edgeTuple ImportedSymbol{..} = (impFromModule, impModule) unique = Set.toList . Set.fromList -- Building Text directly as the Dot package currently doesn't support subgraphs. symbolsGraph :: [ImportedSymbol] -> Text symbolsGraph symbols = "digraph Symbols {\n" <> " rankdir=LR\n" <> " ranksep=5\n" <> T.concat (fmap edge edges) <> T.concat (fmap cluster symbolsByModule) <> "}\n" where edge (from, to, symbol) = " " <> quoted from <> " -> " <> quoted (to <> maybe "" ("." <>) symbol) <> "\n" cluster (moduleName, clusterSymbols) = " subgraph " <> quoted ("cluster_" <> moduleName) <> " {\n" <> " " <> quoted moduleName <> "\n" <> T.concat (fmap (clusterNode moduleName) clusterSymbols) <> " }\n" clusterNode moduleName symbol = " " <> quoted (moduleName <> "." <> symbol) <> "\n" quoted t = "\"" <> t <> "\"" edges = unique . fmap edgeTuple . filter ((==) Internal . impInternal) $ symbols edgeTuple ImportedSymbol{..} = (impFromModule, impModule, impSymbol) unique = Set.toList . Set.fromList symbolsByModule = Map.toList . Map.map (catMaybes . Set.toList) . foldr insertMap Map.empty $ edges insertMap (_, to, symbol) = Map.insertWith Set.union to $ Set.singleton symbol