packages feed

headroom-0.1.0.0: src/Headroom/Command/Run.hs

{-|
Module      : Headroom.Command.Run
Description : Logic for Run command
Copyright   : (c) 2019-2020 Vaclav Svejcar
License     : BSD-3
Maintainer  : vaclav.svejcar@gmail.com
Stability   : experimental
Portability : POSIX

Logic for the @run@ command, used to add or replace license headers in source
code files.
-}
{-# LANGUAGE NoImplicitPrelude #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TupleSections     #-}
module Headroom.Command.Run
  ( commandRun
  )
where

import           Data.Time.Clock.POSIX          ( getPOSIXTime )
import           Headroom.AppConfig             ( AppConfig(..)
                                                , loadAppConfig
                                                , validateAppConfig
                                                )
import           Headroom.Command.Run.Env
import           Headroom.Command.Shared        ( bootstrap )
import           Headroom.FileSystem            ( findFilesByExts
                                                , findFilesByTypes
                                                )
import           Headroom.FileType              ( FileType
                                                , fileTypeByExt
                                                , fileTypeByName
                                                )
import           Headroom.Header                ( Header(..)
                                                , addHeader
                                                , containsHeader
                                                , dropHeader
                                                , replaceHeader
                                                )
import           Headroom.Template              ( Template(..)
                                                , loadTemplate
                                                )
import           Headroom.Template.Mustache     ( Mustache(..) )
import           Headroom.Types                 ( Progress(..)
                                                , RunMode(..)
                                                )
import           RIO                     hiding ( second )
import           RIO.Directory
import           RIO.FilePath                   ( takeBaseName
                                                , takeExtension
                                                , (</>)
                                                )
import qualified RIO.List                      as L
import qualified RIO.Map                       as M
import qualified RIO.Text                      as T


type TemplateType = Mustache

env' :: RunOptions -> LogFunc -> IO Env
env' opts logFunc = do
  let startupEnv = StartupEnv { envLogFunc = logFunc, envRunOptions = opts }
  merged <- runRIO startupEnv mergedAppConfig
  let env = Env { envEnv = startupEnv, envAppConfig = merged }
  return env

-- | Handler for /Run/ command.
commandRun :: RunOptions -- ^ /Run/ command options
           -> IO ()      -- ^ execution result
commandRun opts = bootstrap (env' opts) (roDebug opts) $ do
  startTS <- liftIO getPOSIXTime
  logInfo "Loading source code header templates..."
  templates <- loadTemplates
  logInfo $ "Done, found " <> displayShow (M.size templates) <> " template(s)"
  logInfo "Searching for source code files..."
  sourceFiles <- findSourceFiles (M.keys templates)
  let sourceFilesNum = displayShow . L.length $ sourceFiles
  logInfo $ mconcat
    ["Done, found ", sourceFilesNum, " sources code files(s) to process"]
  (total, skipped) <- processHeaders templates sourceFiles
  endTS            <- liftIO getPOSIXTime
  let (elapsedSeconds, _) = properFraction (endTS - startTS)
  logInfo $ mconcat
    [ "Done: modified "
    , displayShow (total - skipped)
    , ", skipped "
    , displayShow skipped
    , " files in "
    , displayShow (elapsedSeconds :: Integer)
    , " second(s)."
    ]

mergedAppConfig :: (HasRunOptions env, HasLogFunc env) => RIO env AppConfig
mergedAppConfig = do
  runOptions <- view runOptionsL
  configDir  <- getXdgDirectory XdgConfig "headroom"
  currDir    <- getCurrentDirectory
  let locations = [currDir </> ".headroom.yaml", configDir </> "headroom.yaml"]
  logInfo "Loading configuration file(s)..."
  logDebug $ "Configuration files locations: " <> displayShow locations
  appConfigs        <- fmap catMaybes (mapM loadAppConfigSafe locations)
  appConfigFromOpts <- toAppConfig runOptions
  merged            <- mergeAppConfigs $ appConfigFromOpts : appConfigs
  validateAppConfig merged
 where
  loadAppConfigSafe path = catch
    (fmap Just (loadAppConfig path))
    (\ex -> do
      logDebug $ displayShow (ex :: IOException)
      logWarn $ "Skipping missing configuration file: " <> fromString path
      return Nothing
    )
  mergeAppConfigs appConfigs = do
    let merged = mconcat appConfigs
    logDebug $ "Source AppConfig instances: " <> displayShow appConfigs
    logDebug $ "Merged AppConfig: " <> displayShow merged
    return merged

loadTemplates :: (HasAppConfig env, HasLogFunc env)
              => RIO env (M.Map FileType Text)
loadTemplates = do
  appConfig <- view appConfigL
  paths     <- liftIO (mconcat <$> mapM findPaths (acTemplatePaths appConfig))
  logDebug $ "Found template files: " <> displayShow paths
  withTypes <- mapM (\path -> fmap (, path) (extractTemplateType path)) paths
  parsed    <- mapM (\(t, p) -> fmap (t, ) (loadTemplate p))
                    (mapMaybe filterTemplate withTypes)
  rendered <- mapM
    (\(t, p) ->
      fmap (t, ) (renderTemplate (acVariables appConfig) (p :: TemplateType))
    )
    parsed
  return $ M.fromList rendered
 where
  extensions = templateExtensions (Proxy :: Proxy TemplateType)
  findPaths path = findFilesByExts path extensions
  filterTemplate (fileType, path) = (\ft -> Just (ft, path)) =<< fileType

extractTemplateType :: HasLogFunc env => FilePath -> RIO env (Maybe FileType)
extractTemplateType path = do
  let fileType = fileTypeByName . T.pack . takeBaseName $ path
  when (isNothing fileType)
       (logWarn $ "Skipping unrecognized template type: " <> fromString path)
  return fileType

findSourceFiles :: HasAppConfig env => [FileType] -> RIO env [FilePath]
findSourceFiles fileTypes = do
  appConfig <- view appConfigL
  let paths = acSourcePaths appConfig
  liftIO $ fmap concat (mapM (`findFilesByTypes` fileTypes) paths)

processHeaders :: (HasLogFunc env, HasRunOptions env)
               => M.Map FileType Text
               -> [FilePath]
               -> RIO env (Int, Int)
processHeaders templates paths = do
  let filesToProcess = mapMaybe withTemplate (mapMaybe processPath paths)
      zipped         = L.zip [1 ..] filesToProcess
      withProgress   = fmap (\(i, (h, p)) -> (progress i, h, p)) zipped
      progress curr = Progress curr (L.length paths)
  processed <- mapM (\(i, h, p) -> processHeader i h p) withProgress
  return (L.length withProgress, L.length . filter (== True) $ processed)
 where
  withTemplate (fileType, path) =
    fmap (\t -> (Header fileType t, path)) (M.lookup fileType templates)
  processPath path = fmap (, path) (fileTypeFor path)
  fileTypeFor = fileTypeByExt . T.pack . fileExt
  fileExt path = case takeExtension path of
    '.' : xs -> xs
    other    -> other

processHeader :: (HasLogFunc env, HasRunOptions env)
              => Progress
              -> Header
              -> FilePath
              -> RIO env Bool
processHeader progress header path = do
  runOptions  <- view runOptionsL
  fileContent <- readFileUtf8 path
  let hasHeader              = containsHeader (hFileType header) fileContent
      (skipped, action, msg) = chooseAction (roRunMode runOptions) hasHeader
      msg'                   = if skipped then "Skipping file" else msg
  log' $ msg' <> ": " <> fromString path
  writeFileUtf8 path (action header fileContent)
  return skipped
 where
  log' msg = logInfo $ displayShow progress <> "  " <> msg
  chooseAction runMode hasHeader = case runMode of
    Add     -> (hasHeader, addHeader, "Adding header to")
    Drop    -> (not hasHeader, dropHeader . hFileType, "Dropping header from")
    Replace -> if hasHeader
      then (False, replaceHeader, "Replacing header in")
      else chooseAction Add hasHeader