packages feed

file-templates-1.0.0.0: src/Main.hs

module Main where

import Foundation
import Control.Exception
import Control.Monad
import Control.Monad.Trans.Class
import Control.Monad.Trans.State.Strict
import Data.ByteString (ByteString)
import Data.HashMap.Strict (HashMap)
import Data.Traversable
import qualified Data.Attoparsec.ByteString as Attoparsec
import qualified Data.ByteString
import qualified Data.ByteString.Char8
import qualified Data.Char
import qualified Data.HashMap.Strict as HashMap
import qualified Data.List
import qualified GHC.Base
import qualified System.Environment
import qualified System.Exit
import System.Directory
import System.FilePath


-- | Files and file names are parsed into a list of this type.
data Content
  = PromptVar ByteString -- ^ prompt the user for a substitution
  | EnvVar ByteString -- ^ a shell environment variable
  | String ByteString -- ^ normal text


-- | Parse one chunk of 'Content'.
parseChunk :: Attoparsec.Parser Content
parseChunk = Attoparsec.choice
  [ do s <- Attoparsec.takeWhile1 (/=questionMark) -- '?' question mark
       pure (String s)
  , Attoparsec.try $ do
      Attoparsec.word8 questionMark
      mode <- Attoparsec.takeWhile validChar
      Attoparsec.word8 questionMark
      name <- Attoparsec.takeWhile validChar
      Attoparsec.word8 questionMark
      case mode of
        "env" -> pure $ EnvVar name
        "" -> pure $ PromptVar name
        _ -> fail $ "Couldn't recognize mode: " <> bsToChars mode

    -- Allow for stand-alone question marks, like in the common ternary operator ? :
  , (String . Data.ByteString.singleton) <$> Attoparsec.word8 questionMark
  ]
  where
    questionMark = 63
    space = 32
    validChar c = c /= questionMark
               && c /= space


bsToChars :: ByteString -> [Char]
bsToChars = fmap (Data.Char.chr . fromIntegral) . Data.ByteString.unpack


charsToBs :: [Char] -> ByteString
charsToBs = Data.ByteString.pack . fmap (fromIntegral . Data.Char.ord)


parseBS :: ByteString -> Either GHC.Base.String [Content]
parseBS = Attoparsec.parseOnly (Attoparsec.many' parseChunk <* Attoparsec.endOfInput)


-- | Given a list of variables, that the user has already given a meaning to,
-- convert a list of 'Content's into a bytestring, by substituting the strings
-- given by the user and the content of environment variables for the 'Content' variables
-- and concatenating the resulting strings (without spaces, etc.).
convert :: [Content] -> StateT (HashMap ByteString ByteString) IO ByteString
convert con = do
  asked <- get
  bss <- forM con $ \c -> case c of
    String bs -> pure bs
    EnvVar var -> do
      menv <- lift $ System.Environment.lookupEnv (bsToChars var)
      case menv of
        Nothing -> lift $ throwIO $ CouldntFindEnvVar var
        Just str -> pure $ charsToBs str
    PromptVar var ->
      case HashMap.lookup var asked of
        Just res -> pure res
        Nothing -> do
          lift $ Data.ByteString.putStr $ "Please give a value for '" <> var <> "': "
          res <- lift Data.ByteString.getLine
          modify' (HashMap.insert var res)
          pure res
  pure (mconcat bss)


-- | Return all files in a directory.
-- > allFiles "/home/xyz"
-- [".zsh_rc", "Documents/CV.docx", ..]
allFiles :: FilePath -> IO [FilePath]
allFiles = flip go ""
  where
    go dir prefix = do
      content <- listDirectory dir
      dirs <- filterM (doesDirectoryExist . (</>) dir) content
      subfiles <- mconcat <$> traverse (\d -> go (dir </> d) (prefix </> d)) dirs
      let files = fmap (prefix </>) $ diff content dirs
      pure $ files <> subfiles

    -- Unlike \\, this works in O(n) time relative to the first list.
    diff (x:xs) (y:ys) | x == y = diff xs ys
    diff (x:xs) (y:ys) | x /= y = x : diff xs (y:ys)
    diff xs [] = xs
    diff [] ys = undefined


data TemplateExceptions
  = CouldntParseFilename ByteString GHC.Base.String -- ^ name and attoparsec info
  | CouldntParseFile ByteString GHC.Base.String -- ^ name and attoparsec info
  | CouldntFindEnvVar ByteString
  deriving (Eq, Show)

instance Exception TemplateExceptions where
  displayException (CouldntParseFilename name info) =
    "Couldn't parse filename '" <> bsToChars name <> "': \n" <> info
  displayException (CouldntParseFile name info) =
    "Couldn't parse file '" <> bsToChars name <> "': \n" <> info
  displayException (CouldntFindEnvVar var) =
    "Couldn't find the environment variable '" <> bsToChars var <> "'."


-- | Print the first argument and then the second,
-- and wait for 'j' or 'n' (spaces get trimmed, case-insensitive).
-- When the user enters something else, print the second argument
-- and wait for another input and so forth.
-- > askUser "This is bad." "Continue? [j/N]"
askUser :: ByteString -> ByteString -> IO Bool
askUser msg repeat = do
  Data.ByteString.Char8.putStrLn msg
  go
  where
    go = do
      Data.ByteString.Char8.putStr repeat
      r <- Data.ByteString.Char8.getLine
      let trimmed = fst $ Data.ByteString.Char8.spanEnd Data.Char.isSpace
                        $ Data.ByteString.Char8.dropWhile Data.Char.isSpace r
      let lower = Data.ByteString.Char8.map Data.Char.toLower trimmed
      case lower of
        "j" -> pure True
        "n" -> pure False
        _ -> go


main :: IO ()
main = do
  [name] <- getArgs
  templateDir <- (</> toList name) <$> getAppUserDataDirectory "templates"
  templateFiles <- allFiles templateDir

  -- Parse the filenames and resolve the variables in them.
  (destFiles, asked) <- runStateT
    (mapM (convert <=< parseFilename . charsToBs) templateFiles)
    HashMap.empty

  -- Check whether some files are already present.
  existingFiles <- filterM (doesFileExist . bsToChars) destFiles
  when (not $ null existingFiles) $ do
    overwrite <- askUser
      ("The following files already exist:\n"
      <> Data.ByteString.Char8.unlines (fmap ("  "<>) existingFiles)
      ) "Overwrite? [y/N]"
    when (not overwrite) $ do
      System.Exit.exitFailure

  -- Copy the file content.
  flip evalStateT asked
    $ forM_ (Data.List.zip (fmap (templateDir </>) templateFiles)
                           (fmap bsToChars destFiles))
    $ \(tf, df) -> do
      bs <- lift $ Data.ByteString.readFile tf
      cs <- parseFile df bs
      completed <- convert cs
      lift $ createDirectoryIfMissing True (dropFileName df)
      lift $ Data.ByteString.writeFile df completed

  where
    parseFilename bs = case parseBS bs of
      Right a -> pure a
      Left s -> lift $ throwIO $ CouldntParseFilename bs s

    parseFile name bs = case parseBS bs of
      Right a -> pure a
      Left s -> lift $ throwIO $ CouldntParseFile (Data.ByteString.Char8.pack name) s