packages feed

glob-imports-0.0.1.0: src/GlobImports/Exe.hs

-- | 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
-- <https://hackage.haskell.org/package/persistent-discover
-- @persistent-discover@> 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)