packages feed

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

{-# LANGUAGE OverloadedStrings #-}

-- | 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 Control.Applicative
import Control.Exception (SomeException, catch)
import Control.Monad (guard, when)
import qualified Data.ByteString.Lazy as LBS
import Data.Char
import Data.List
import Data.List.Split (splitOn)
import Data.Maybe
import qualified Data.Text as Text
import Data.Text.Encoding (encodeUtf8, decodeUtf8)
import System.FilePath
import System.Process.Typed

-- | 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]
    }

printDebug :: Bool -> String -> IO ()
printDebug enabled str = when enabled $ putStrLn ("[DEBUG] " ++ str)

-- |
--
-- @since 0.1.0.0
spliceImports
    :: Source
    -> SourceContents
    -> Destination
    -> Maybe FilePath
    -> String
    -> [String]
    -> Bool
    -> Affix
    -> IO ()
spliceImports (Source src) (SourceContents srcContents) (Destination dest) msearchDir pat prefixes debug affix = do
    let
        (sourceDir, _file) = splitFileName src
        searchDir = fromMaybe sourceDir msearchDir
        excludePrefixFilter target = not $ any (`isPrefixOf` target) prefixes

    printDebug debug $ "searching directory: " ++ searchDir
    printDebug debug $ "searching with pattern: " ++ pat
    printDebug debug $ "excluding file name: " ++ src
    eitherFiles <- fmap (filter (essentiallyDistinct src)) <$> getFiles searchDir pat
    files <- case eitherFiles of
        Left e -> error e
        Right f -> pure f
    let
        filteredFiles = filter excludePrefixFilter files
    printDebug debug $ "including files:\n" ++ intercalate "\n|  " filteredFiles
    let
        input =
            AllModelsFile
                { amfModuleBase =
                    fromJust $ pathToModule src
                , amfModuleImports =
                    mapMaybe pathToModule (fmap (searchDir </>) filteredFiles)
                }
        output =
            renderFile input affix srcContents

    writeFile dest output
    where
      essentiallyDistinct :: FilePath -> FilePath -> Bool
      essentiallyDistinct l r = simplifyPath l /= simplifyPath r
      simplifyPath :: String -> String
      simplifyPath = (intercalate "/") . (filter (/= mempty)) . (splitOn "/")

-- | Returns a sorted list of relative paths to all files in the given directory.
getFiles
    :: FilePath
    -- ^ The glob pattern to filter with.
    -> String
    -- ^ The directory to search.
    -> IO (Either String [FilePath])
getFiles baseDir pat = do
    (exitCode, out, err) <- (readProcess $ proc "find" [baseDir, "-wholename", pat]) `catch` handler
    pure $ case exitCode of
        ExitSuccess -> Right . sort . lines . Text.unpack . decodeUtf8 . LBS.toStrict $ out
        ExitFailure _ -> Left . Text.unpack . decodeUtf8 . LBS.toStrict $ err
    where
      handler :: SomeException -> IO (ExitCode, LBS.ByteString, LBS.ByteString)
      handler e = do
        pure $ ( ExitFailure 1
               , ""
               , mconcat [ "unable to invoke `find`. Please make sure that `find` is executable and in PATH.\n"
                         , "("
                         , LBS.fromStrict . encodeUtf8 . Text.pack $ show e
                         , ")\n"
                         ]
               )

renderFile
    :: AllModelsFile
    -> Affix
    -> String
    -> String
renderFile amf affix 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' -> case affix of
                Prefix -> "import qualified " <> moduleName mod'
                Suffix -> "import " <> moduleName mod' <> " qualified")
            (amfModuleImports amf)

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)

-- | How to qualify imports.
-- 
-- @since 0.0.3.0
data Affix
    = Prefix -- ^ @import qualified M@
    | Suffix -- ^ @import M qualified@
    deriving (Eq, Show)