-- | This preprocessor splices in imports to the file you define it in. Haskell -- source files are discovered according to a glob relative to the file the code -- is defined in. This utility is useful when metaprogramming with a group of -- related files, where you want to use @TemplateHaskell@ or similar -- -- By default, the glob is for all modules in the directory containing the -- source file. Imports are qualified with the full module name to avoid -- potential import conflicts. The pre-processor will splice in a top-level -- value @_importedModules :: [String]@ which contains the fully qualified -- names of the modules that were imported. -- -- You may want to disable warnings for redundant imports, if you are only using -- type class information. A future option to the library may only do empty -- import lists, to only get access to type class instances. -- -- As an example, consider the -- utility, which is inspired by @hspec-discover@. That -- utility will perform the following transformation: -- -- @ -- -- src/PersistentModels/All.hs -- -- {-# OPTIONS_GHC -F -pgmF persistent-discover #-} -- @ -- -- Then it will translate to: -- -- @ -- -- src/PersistentModels/All.hs -- -- module PersistentModels.All where -- -- import PersistentModels.Foo () -- import PersistentModels.Bar () -- import PersistentModels.Baz () -- -- allEntityDefs :: [EntityDef] -- allEntityDefs = $(discoverEntities) -- @ -- -- With this package, we can generalize the overall pattern. The new source -- module will look like this: -- -- @ -- -- src/PersistentModels/All.hs -- -- {-# OPTIONS_GHC -F -pgmF glob-imports #-} -- -- module PersistentModels.All where -- -- import Database.Persist.Sql -- {- GLOB_IMPORTS_SPLICE -} -- -- allEntityDefs :: [EntityDef] -- allEntityDefs = $(discoverEntities) -- @ -- -- This preprocessor will convert this into this form: -- -- @ -- -- src/PersistentModels/All.hs -- -- module PersistentModels.All where -- -- import Database.Persist.Sql -- import qualified PersistentModels.Foo -- import qualified PersistentModels.Bar -- import qualified PersistentModels.Baz -- -- allEntityDefs :: [EntityDef] -- allEntityDefs = $(discoverEntities) -- @ -- -- Note how the only difference is that imports have been spliced in. This -- allows you to more flexibly customize how the code works. -- -- @since 0.1.0.0 module GlobImports.Exe where import System.FilePath import Control.Monad (guard, filterM) import Control.Monad.State import Data.String import Data.DList (DList(..)) import qualified Data.DList as DList import Data.Foldable (for_) import System.Directory import Data.List import Data.Char import Control.Applicative import Data.Maybe -- | The source file location. This is the first argument passed to the -- preprocessor. newtype Source = Source { unSource :: FilePath } -- | The source file contents. This is the 'String' contained in the file of the -- second argument passed to the preprocessor. newtype SourceContents = SourceContents { unSourceContents :: String } -- | The destination file path to write the final source to. This is the third -- argument passed to the preprocessor. newtype Destination = Destination { unDestination :: FilePath } data AllModelsFile = AllModelsFile { amfModuleBase :: Module , amfModuleImports :: [Module] } -- | -- -- @since 0.1.0.0 spliceImports :: Source -> SourceContents -> Destination -> IO () spliceImports (Source src) (SourceContents srcContents) (Destination dest) = do let (dir, file) = splitFileName src files <- filter (/= file) <$> getFilesRecursive dir let input = AllModelsFile { amfModuleBase = fromJust $ pathToModule src , amfModuleImports = mapMaybe pathToModule (fmap (dir ) files) } output = renderFile input srcContents writeFile dest output -- | Returns a list of relative paths to all files in the given directory. getFilesRecursive :: FilePath -- ^ The directory to search. -> IO [FilePath] getFilesRecursive baseDir = sort <$> go [] where go :: FilePath -> IO [FilePath] go dir = do c <- map (dir ) . filter (`notElem` [".", ".."]) <$> getDirectoryContents (baseDir dir) dirs <- filterM (doesDirectoryExist . (baseDir )) c >>= mapM go files <- filterM (doesFileExist . (baseDir )) c return (files ++ concat dirs) renderFile :: AllModelsFile -> String -> String renderFile amf originalContents = concatMap unlines [ modulePrior , newImportLines , newModuleRest ] where originalLines = lines originalContents (modulePrior, moduleRest) = case break ("GLOB_IMPORTS_SPLICE" `isInfixOf`) originalLines of (_, []) -> error $ unlines [ "While processing the module, I was unable to find a comment with GLOB_IMPORTS_SPLICE." , "I need this to know where to splice imports into the file. Please add a comment like " , "this to the source file in the import section: " , "" , "-- GLOB_IMPORTS_SPLICE" ] (prior, (_globImportLine : rest)) -> (prior, rest) newModuleRest = let (remainingModule, lastImportLine) = break ("import" `isPrefixOf`) (reverse moduleRest) quoteModuleName mod' = "\"" <> moduleName mod' <> "\"" mkFirstModuleLine mod' = " [ " <> quoteModuleName mod' mkRestModuleLine mod' = " , " <> quoteModuleName mod' newLines = reverse case amfModuleImports amf of [] -> [] (firstModule : restModules) -> [ "_importedModules :: [String]" , "_importedModules =" , mkFirstModuleLine firstModule ] <> map mkRestModuleLine restModules <> [ " ]"] in reverse (concat [remainingModule, newLines, lastImportLine]) newImportLines = map (\mod' -> "import qualified " <> moduleName mod') (amfModuleImports amf) -- render do -- let -- modName = -- moduleName $ amfModuleBase amf -- renderLine do -- "{-# LINE 1 " -- fromString $ show modName -- " #-}" -- "{-# LANGUAGE TemplateHaskell #-}" -- "" -- renderLine do -- "module " -- fromString $ modName -- " where" -- "" -- for_ (amfModuleImports amf) \mod' -> -- renderLine do -- "import " -- fromString $ moduleName mod' -- " ()" -- "" -- "import Database.Persist.TH (discoverEntities)" -- "import Database.Persist.Types (EntityDef)" -- "" -- "-- | All of the entity definitions, as discovered by the @glob-imports@ utility." -- "allEntityDefs :: [EntityDef]" -- "allEntityDefs = $(discoverEntities)" -- -- -- -- | Derive module name from specified path. -- -- pathToModule :: FilePath -> Module -- -- pathToModule f = -- -- Module -- -- { moduleName = -- -- intercalate "." $ mapMaybe go $ splitDirectories f -- -- , modulePath = -- -- f -- -- } -- -- where -- -- go :: String -> Maybe String -- -- go (c:cs) = -- -- Just (toUpper c : cs) -- -- fileName = last $ splitDirectories f -- -- m:ms = takeWhile (/='.') fileName -- | data Module = Module { moduleName :: String , modulePath :: FilePath } deriving (Eq, Show) mkModulePieces :: FilePath -> [String] mkModulePieces fp = do let extension = takeExtension fp guard (extension == ".hs" || extension == ".lhs") reverse . takeWhile (not . isLowerFirst) . reverse . filter noDots . splitDirectories . dropExtension $ fp where noDots x = "." /= x && ".." /= x isLowerFirst :: String -> Bool isLowerFirst [] = True isLowerFirst (c:_) = isLower c pathToModule :: FilePath -> Maybe Module pathToModule file = do case mkModulePieces file of [] -> empty x : xs -> do guard $ all isValidModuleName (x : xs) pure Module { moduleName = intercalate "." (x:xs) , modulePath = file } -- | Returns True if the given string is a valid task module name. -- See `Cabal.Distribution.ModuleName` (http://git.io/bj34) isValidModuleName :: String -> Bool isValidModuleName [] = False isValidModuleName (c:cs) = isUpper c && all isValidModuleChar cs -- | Returns True if the given Char is a valid taks module character. isValidModuleChar :: Char -> Bool isValidModuleChar c = isAlphaNum c || c == '_' || c == '\'' -- | Convert a String in camel case to snake case. casify :: String -> String casify str = intercalate "_" $ groupBy (\a b -> isUpper a && isLower b) str stripSuffix :: Eq a => [a] -> [a] -> Maybe [a] stripSuffix suffix str = reverse <$> stripPrefix (reverse suffix) (reverse str)