packages feed

steeloverseer-2.0.1.0: app/Main.hs

module Main where

import Sos
import Sos.Utils

import qualified System.FSNotify.Streaming as FSNotify

import Control.Concurrent.Async
import Control.Monad
import Control.Monad.Except
import Data.ByteString        (ByteString)
import Data.List.NonEmpty     (NonEmpty(..))
import Data.Monoid
import Data.Yaml              (decodeFileEither, prettyPrintParseException)
import Options.Applicative
import Streaming
import System.Directory
import System.Exit
import System.FilePath
import System.IO

import qualified Data.Foldable     as Foldable
import qualified Streaming.Prelude as S


version :: String
version = "Steel Overseer 2.0.1.0"

data Options = Options
  { optTarget   :: FilePath
  , optRCFile   :: FilePath
  , optCommands :: [RawTemplate]
  , optPatterns :: [RawPattern]
  } deriving Show

main :: IO ()
main = execParser opts >>= main'
 where
  opts = info (helper <*> optsParser)
    ( fullDesc
   <> progDesc "A file watcher and development tool."
   <> header version )

  optsParser :: Parser Options
  optsParser = Options
    <$> strArgument
     ( help "Optional file or directory to watch for changes."
       <> metavar "TARGET"
       <> value "."
       <> showDefault )
    <*> strOption
     ( help "Optional rcfile to read patterns and commands from."
       <> long "rcfile"
       <> value ".sosrc"
       <> showDefault )
    <*> many (fmap packBS (strOption
      ( long "command"
        <> short 'c'
        <> help "Add command to run on file event."
        <> metavar "COMMAND" )))
    <*> many (fmap packBS (strOption
      ( long "pattern"
        <> short 'p'
        <> help "Add pattern to match on file path. Only relevant if the target is a directory. (default: .*)"
        <> metavar "PATTERN" )))

main' :: Options -> IO ()
main' Options{..} = do
  -- Parse .sosrc rules.
  rc_rules <- parseSosrc optRCFile

  -- Parse cli rules, where one rule is created per pattern that executes
  -- each of @optCommands@ sequentially.
  cli_rules <- do
    let patterns :: [RawPattern]
        patterns =
          case (rc_rules, optPatterns) of
            -- If there are no commands in .sosrc, and no patterns
            -- specified on the command line, default to ".*"
            ([], []) -> [".*"]
            _ -> optPatterns
    runSos (mapM (`buildRule` optCommands) patterns)

  (target, rules) <- do
    is_dir  <- doesDirectoryExist optTarget
    is_file <- doesFileExist optTarget
    case (is_dir, is_file) of
      (True, _) -> pure (optTarget, cli_rules ++ rc_rules)
      -- If the target is a single file, completely ignore the .sosrc
      -- commands and the cli commands.
      (_, True) -> do
        rule <- runSos (buildRule (packBS optTarget) optCommands)
        pure (takeDirectory optTarget, [rule])
      _ -> do
        putStrLn ("Target " ++ optTarget ++ " is not a file or directory.")
        exitFailure

  putStrLn "Hit Ctrl+C to quit."

  job_queue <- newJobQueue

  let enqueue_thread :: IO a
      enqueue_thread =
        runSos (sosEnqueueJobs rules (watchTree target) job_queue)

      -- Run jobs forever, only stopping to prompt whether or not to run
      -- enqueued jobs when a job fails. This way, the failing job's output
      -- will not be lost by subsequent jobs' outputs without the user's
      -- consent.
      dequeue_thread :: IO a
      dequeue_thread = forever $ do
        dequeueJob job_queue >>= \case
          JobSuccess -> pure ()
          JobFailure -> do
            jobQueueLength job_queue >>= \case
              0 -> pure ()
              n -> do
                putStrLn (yellow (show n ++ " job(s) still pending."))

                hSetBuffering stdin NoBuffering
                continue <- fix (\prompt -> do
                  putStr (yellow "Press 'c' to continue, 'p' to print, or 'a' to abort: ")
                  hFlush stdout
                  (getChar <* putStrLn "") >>= \case
                    'c' -> pure True
                    'a' -> pure False
                    'p' -> do
                      jobs <- jobQueueJobs job_queue
                      Foldable.forM_ (jobCommands <$> jobs) $
                        (\(c:|cs) -> do
                          putStrLn ("- " ++ c)
                          mapM_ (putStrLn . ("  " ++)) cs)
                      prompt
                    _ -> prompt)
                hSetBuffering stdin LineBuffering

                unless continue $ do
                  n' <- jobQueueLength job_queue
                  clearJobQueue job_queue
                  putStrLn (red ("Aborted " ++ show n' ++ " job(s)."))

  race_ enqueue_thread dequeue_thread

watchTree
  :: forall m a.
     MonadResource m
  => FilePath -> Stream (Of FileEvent) m a
watchTree target = do
  cwd <- liftIO getCurrentDirectory

  let config :: FSNotify.WatchConfig
      config = FSNotify.defaultConfig
        { FSNotify.confDebounce = FSNotify.Debounce 0.1 }

      stream :: Stream (Of FSNotify.Event) m a
      stream = FSNotify.watchTree config target (const True)

  S.for stream (\case
    FSNotify.Added    path _ -> S.yield (FileAdded    (go cwd path))
    FSNotify.Modified path _ -> S.yield (FileModified (go cwd path))
    FSNotify.Removed  _    _ -> pure ())
 where
  go :: FilePath -> FilePath -> ByteString
  go cwd path = packBS (makeRelative cwd path)

--------------------------------------------------------------------------------

-- Parse a list of rules from an rcfile.
parseSosrc :: FilePath -> IO [Rule]
parseSosrc sosrc = do
  exists <- doesFileExist sosrc
  if exists
    then
      decodeFileEither sosrc >>= \case
        Left err -> do
          putStrLn ("Error parsing " ++ show sosrc ++ ":\n" ++ prettyPrintParseException err)
          exitFailure
        Right (raw_rules :: [RawRule]) -> do
          rules <- runSos (mapM buildRawRule raw_rules)
          putStrLn (case length raw_rules of
                      1 -> "Found 1 rule in " ++ show sosrc
                      n -> "Found " ++ show n ++ " rules in " ++ show sosrc)
          pure (concat rules)
    else pure []