{-# LANGUAGE CPP #-}

------------------------------------------------------------------------------
-- |
-- Module: Xmobar.App.Compile
-- Copyright: (c) 2018 Jose Antonio Ortega Ruiz
-- License: BSD3-style (see LICENSE)
--
-- Maintainer: jao@gnu.org
-- Stability: unstable
-- Portability: portable
-- Created: Mon Nov 26, 2018 03:36
--
--
-- Utlities to compile xmobar executables on the fly
--
------------------------------------------------------------------------------


module Xmobar.App.Compile(recompile, trace, xmessage) where

import Control.Monad.IO.Class
import Control.Monad.Fix (fix)
import Control.Exception.Extensible (try, bracket, SomeException(..))
import qualified Control.Exception.Extensible as E
import Control.Monad (filterM, when)
import Data.List ((\\))
import Data.Maybe (isJust)
import System.FilePath((</>), takeExtension)
import System.IO
import System.Directory
import System.Process
import System.Exit
import System.Posix.Process(executeFile, forkProcess, getAnyProcessStatus)
import System.Posix.Types(ProcessID)
import System.Posix.Signals

isExecutable :: FilePath -> IO Bool
isExecutable f =
  E.catch (executable <$> getPermissions f) (\(SomeException _) -> return False)

checkBuildScript :: Bool -> FilePath -> IO Bool
checkBuildScript verb buildscript = do
  exists <- doesFileExist buildscript
  if exists
    then do
      isExe <- isExecutable buildscript
      if isExe
        then do
          trace verb $ "Xmobar will use build script at "
                       ++ show buildscript ++ " to recompile."
          return True
        else do
          trace verb $ unlines
            [ "Xmobar will not use build script, because "
              ++ show buildscript ++ " is not executable."
            , "Suggested resolution to use it: chmod u+x "
              ++ show buildscript
            ]
          return False
    else do
      trace verb $ "Xmobar will use ghc to recompile, because "
                   ++ show buildscript ++ " does not exist."
      return False

shouldRecompile :: Bool -> FilePath -> FilePath -> FilePath -> IO Bool
shouldRecompile verb src bin lib = do
  libTs <- mapM getModTime . filter isSource =<< allFiles lib
  srcT <- getModTime src
  binT <- getModTime bin
  if any (binT <) (srcT : libTs)
    then do
      trace verb "Xmobar recompiling because some files have changed."
      return True
    else do
      trace verb $ "Xmobar skipping recompile because it is not forced "
                   ++ "(e.g. via --recompile), and not any *.hs / *.lhs / *.hsc"
                   ++ " files in lib/ have been changed."
      return False
  where isSource = flip elem [".hs",".lhs",".hsc"] . takeExtension
        allFiles t = do
            let prep = map (t</>) . Prelude.filter (`notElem` [".",".."])
            cs <- prep <$> E.catch (getDirectoryContents t)
                                   (\(SomeException _) -> return [])
            ds <- filterM doesDirectoryExist cs
            concat . ((cs \\ ds):) <$> mapM allFiles ds
        getModTime f = E.catch (Just <$> getModificationTime f)
                               (\(SomeException _) -> return Nothing)

runProc :: FilePath -> [String] -> FilePath -> Handle -> IO ProcessHandle
runProc bin args dir eh =
  runProcess bin args (Just dir) Nothing Nothing Nothing (Just eh)

xmessage :: String -> IO System.Posix.Types.ProcessID
xmessage msg = forkProcess $
  executeFile "xmessage" True ["-default", "okay", replaceUnicode msg] Nothing
  where -- Replace some of the unicode symbols GHC uses in its output
        replaceUnicode = map $ \c -> case c of
         '\8226' -> '*'  -- •
         '\8216' -> '`'  -- ‘
         '\8217' -> '`'  -- ’
         _ -> c

ghcErrorMsg :: (Monad m, Show a) => String -> a -> String -> m String
ghcErrorMsg src status ghcErr = return . unlines $
  ["Error detected while loading xmobar configuration file: " ++ src]
  ++ lines (if null ghcErr then show status else ghcErr)
  ++ ["","Please check the file for errors."]

-- | A 'trace' for the 'X' monad. Logs a string to stderr. The result may
-- be found in your .xsession-errors file
trace :: MonadIO m => Bool -> String -> m ()
trace verb msg = when verb (liftIO $ hPutStrLn stderr msg)

-- | 'recompile force', recompile the xmobar configuration file when
-- any of the following apply:
--
--      * force is 'True'
--
--      * the execName executable does not exist
--
--      * the xmobar executable is older than .hs or any file in
--        the @lib@ directory (under the configuration directory).
--
-- The -i flag is used to restrict recompilation to the xmobar.hs file only,
-- and any files in the aforementioned @lib@ directory.
--
-- Compilation errors (if any) are logged to the @xmobar.errors@ file
-- in the given directory.  If GHC indicates failure with a
-- non-zero exit code, an xmessage displaying that file is spawned.
--
-- 'False' is returned if there are compilation errors.
--
recompile :: MonadIO m => String -> String -> Bool -> Bool -> m Bool
recompile dir execName force verb = liftIO $ do
    let bin  = dir </> execName
        err  = dir </> (execName ++ ".errors")
        src  = dir </> (execName ++ ".hs")
        lib  = dir </> "lib"
        script = dir </> "build"
    useScript <- checkBuildScript verb script
    sc <- if useScript || force
          then return True
          else shouldRecompile verb src bin lib
    if sc
      then do
        uninstallSignalHandlers
        status <- bracket (openFile err WriteMode) hClose $
                    \errHandle ->
                      waitForProcess =<<
                        if useScript
                        then runScript script bin dir errHandle
                        else runGHC bin dir errHandle
        installSignalHandlers
        if status == ExitSuccess
            then trace verb "Xmobar recompilation process exited with success!"
            else do
                msg <- readFile err >>= ghcErrorMsg src status
                hPutStrLn stderr msg
                exitWith (ExitFailure 1)
        return (status == ExitSuccess)
      else return True
 where opts bin = ["--make" , execName ++ ".hs" , "-i" , "-ilib"
                  , "-fforce-recomp" , "-main-is", "main" , "-v0"]
#ifdef THREADED_RUNTIME
                  ++ ["-threaded"]
#endif
#ifdef DRTSOPTS
                  ++ ["-rtsopts", "-with-rtsopts", "-V0"]
#endif
                  ++ ["-o", bin]
       runGHC bin = runProc "ghc" (opts bin)
       runScript script bin = runProc script [bin]

-- | Ignore SIGPIPE to avoid termination when a pipe is full, and SIGCHLD to
-- avoid zombie processes, and clean up any extant zombie processes.
installSignalHandlers :: MonadIO m => m ()
installSignalHandlers = liftIO $ do
    installHandler openEndedPipe Ignore Nothing
    installHandler sigCHLD Ignore Nothing
    (try :: IO a -> IO (Either SomeException a))
      $ fix $ \more -> do
        x <- getAnyProcessStatus False False
        when (isJust x) more
    return ()

uninstallSignalHandlers :: MonadIO m => m ()
uninstallSignalHandlers = liftIO $ do
    installHandler openEndedPipe Default Nothing
    installHandler sigCHLD Default Nothing
    return ()