packages feed

goldplate-0.2.2.1: src/Goldplate.hs

{-# LANGUAGE BangPatterns        #-}
{-# LANGUAGE DeriveFoldable      #-}
{-# LANGUAGE DeriveFunctor       #-}
{-# LANGUAGE DeriveTraversable   #-}
{-# LANGUAGE FlexibleInstances   #-}
{-# LANGUAGE LambdaCase          #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE RecordWildCards     #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Goldplate
    ( main

    , Options (..)
    , defaultOptions
    , mainWith

    , Spec (..)
    , Assert (..)
    ) where

import           Control.Applicative       (optional, (<|>))
import qualified Control.Concurrent.Async  as Async
import qualified Control.Concurrent.MVar   as MVar
import           Control.Exception         (Exception, finally, throwIO)
import           Control.Monad             (forM, forM_, mzero, unless, when)
import qualified Data.Aeson                as A
import qualified Data.Aeson.Encode.Pretty  as Aeson.Pretty
import           Data.Algorithm.Diff
import           Data.Algorithm.DiffOutput
import qualified Data.ByteString           as B
import qualified Data.ByteString.Lazy      as BL
import qualified Data.Foldable             as F
import           Data.Function             (on)
import qualified Data.HashMap.Strict       as HMS
import qualified Data.IORef                as IORef
import qualified Data.List                 as List
import           Data.Maybe                (catMaybes)
import qualified Data.Text                 as T
import qualified Data.Text.Encoding        as T
import           Data.Version              (showVersion)
import qualified Options.Applicative       as OA
import           Paths_goldplate           (version)
import qualified System.Directory          as Dir
import           System.Environment        (getEnvironment)
import           System.Exit               (ExitCode (..), exitWith)
import qualified System.FilePath           as FP
import qualified System.FilePath.Glob      as Glob
import qualified System.IO                 as IO
import qualified System.Process            as Process
import qualified Text.Regex.PCRE.Simple    as Pcre
import           Text.Splice

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

-- | This is a little helper type that we use when we either support multiple
-- things (e.g. lines of stdin) or a single thing (e.g. a single string of
-- stdin).
data Multiple a = Multiple [a] | Single a
    deriving (Foldable, Functor, Traversable)

instance A.FromJSON a => A.FromJSON (Multiple a) where
    parseJSON v = (Multiple <$> A.parseJSON v) <|> (Single <$> A.parseJSON v)

multipleToList :: Multiple a -> [a]
multipleToList = F.toList

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

-- | A specification that we parse from a JSON file.
-- The type parameter indicates the fields that we allow splicing over.
data Spec a = Spec
    { specInputFiles :: !(Maybe a)
    , specCommand    :: !a
    , specArguments  :: ![a]
    , specStdin      :: !(Maybe (Multiple a))
    , specEnv        :: ![(a, a)]
    , specWorkDir    :: !(Maybe a)
    , specAsserts    :: ![Assert a]
    } deriving (Foldable, Functor, Traversable)

instance A.FromJSON (Spec String) where
    parseJSON = A.withObject "FromJSON Spec" $ \o -> Spec
        <$> o A..:? "input_files"
        <*> o A..:  "command"
        <*> o A..:? "arguments" A..!= []
        <*> o A..:? "stdin"
        <*> (maybe [] HMS.toList <$> o A..:? "environment")
        <*> o A..:? "working_directory"
        <*> o A..:  "asserts"

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

-- | Post processing of stdout or created files.
type PostProcess = [PostProcessStep]

data PostProcessStep
    = PrettifyJsonStep
    | ReplaceStep !Pcre.Regex !T.Text

instance A.FromJSON PostProcessStep where
    parseJSON = \case
        A.String "prettify_json" -> pure PrettifyJsonStep
        A.Object o -> ReplaceStep
            <$> (do
                    p <- o A..: "pattern"
                    either (fail . show) return (Pcre.compile copts eopts p))
            <*> o A..: "replacement"
        _ -> mzero
      where
        copts = Pcre.optionUtf8 <> Pcre.optionMultiline
        eopts = mempty

postProcess :: PostProcess -> B.ByteString -> B.ByteString
postProcess ps bs0 = List.foldl' (flip postProcessStep) bs0 ps

postProcessStep :: PostProcessStep -> B.ByteString -> B.ByteString
postProcessStep PrettifyJsonStep bs = maybe bs
    (BL.toStrict . Aeson.Pretty.encodePretty' prettyConfig)
    (A.decodeStrict bs :: Maybe A.Value)
  where
    prettyConfig = Aeson.Pretty.defConfig
        { Aeson.Pretty.confIndent  = (Aeson.Pretty.Spaces 2)
        , Aeson.Pretty.confCompare = compare
        }

postProcessStep (ReplaceStep regex replacement) bs =
    either (const bs) T.encodeUtf8 .
    Pcre.replaceAll regex replacement $ T.decodeUtf8 bs

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

-- | Asserts that can happen after an execution.
data Assert a
    = ExitCodeAssert !Int
    | StdoutAssert
        { stdoutFilePath    :: !a
        , stdoutPostProcess :: !PostProcess
        }
    | StderrAssert
        { stderrFilePath    :: !a
        , stderrPostProcess :: !PostProcess
        }
    | CreatedFileAssert
        { createdFilePath        :: !a
        , createdFileContents    :: !(Maybe a)
        , createdFilePostProcess :: !PostProcess
        }
    | CreatedDirectoryAssert
        { createdDirectoryPath   :: !a
        }
    deriving (Foldable, Functor, Traversable)

instance A.FromJSON a => A.FromJSON (Assert a) where
    parseJSON = A.withObject "FromJSON Assert" $ \o -> do
        options <- sequenceA $ map optional
            [ ExitCodeAssert <$> o A..: "exit_code"
            , StdoutAssert <$> o A..: "stdout" <*> pp o
            , StderrAssert <$> o A..: "stderr" <*> pp o
            , CreatedFileAssert
                <$> o A..: "created_file" <*> o A..:? "contents" <*> pp o
            , CreatedDirectoryAssert <$> o A..: "created_directory"
            ]
        case catMaybes options of
            [opt] -> pure opt
            []    -> fail "no assert discriminator"
            opts  -> fail $ "multiple assert discriminators: " ++
                List.intercalate ", " (map assertDiscriminator opts)
      where
        pp o = maybe [] multipleToList <$> o A..:? "post_process"

assertDiscriminator :: Assert a -> String
assertDiscriminator (ExitCodeAssert     _)     = "exit_code"
assertDiscriminator (StdoutAssert       _ _)   = "stdout"
assertDiscriminator (StderrAssert       _ _)   = "stderr"
assertDiscriminator (CreatedFileAssert  _ _ _) = "created_file"
assertDiscriminator (CreatedDirectoryAssert _) = "created_directory"

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

data Logger = Logger
    { logDebug :: [String] -> IO ()
    , logError :: [String] -> IO ()
    , logOut   :: [String] -> IO ()
    }

makeLogger :: Bool -> IO Logger
makeLogger verbose = do
    lock <- MVar.newMVar ()
    let writeLines h ls = MVar.withMVar lock $ \() -> mapM_ (IO.hPutStrLn h) ls
    return Logger
        { logDebug = if verbose then writeLines IO.stderr else \_ -> pure ()
        , logError = writeLines IO.stderr
        , logOut   = writeLines IO.stdout
        }

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

-- | A plain 'Spec' parsed from a JSON file usually gives us one more or
-- executions of a process.  This contains more info than a plain 'Spec'.
data Execution = Execution
    { executionSpec      :: Spec String
    , executionInputFile :: Maybe FilePath
    , executionSpecPath  :: FilePath
    , executionSpecName  :: String
    , executionDirectory :: FilePath
    }

specExecutions :: FilePath -> Spec String -> IO [Execution]
specExecutions specPath spec = do
    absoluteSpecPath <- Dir.makeAbsolute specPath
    let (specDirectory, specBaseName) = FP.splitFileName specPath
        specName                      = FP.dropExtension specBaseName

        mkAbsoluteWorkDir :: FilePath -> FilePath
        mkAbsoluteWorkDir dir | FP.isRelative dir = specDirectory FP.</> dir
                              | otherwise         = dir

        workDirectory = maybe specDirectory mkAbsoluteWorkDir (specWorkDir spec)

    -- Compute initial environment to get input files.
    env0 <- getEnvironment
    let env1 =
            List.nubBy ((==) `on` fst) $
                ("GOLDPLATE_NAME", specName) :
                ("GOLDPLATE_FILE", absoluteSpecPath) :
                ("GOLDPLATE_BASENAME", specBaseName) :
                specEnv spec ++ env0

    -- Get a list of concrete input files (a list maybes).
    concreteInputFiles <- case specInputFiles spec of
        Nothing    -> return [Nothing]
        Just glob0 -> do
            glob <- hoistEither $ splice env1 glob0
            inputFiles <- Dir.withCurrentDirectory workDirectory $ do
                matches <- globCurrentDir glob
                length matches `seq` return matches
            return (map (Just . FP.normalise) inputFiles)

    -- Create an execution for every concrete input.
    forM concreteInputFiles $ \mbInputFile -> do
        -- Extend environment.
        let env2 = case mbInputFile of
                Nothing        -> env1
                Just inputFile ->
                    ("GOLDPLATE_INPUT_FILE", inputFile) :
                    ("GOLDPLATE_INPUT_NAME", FP.dropExtension inputFile) :
                    ("GOLDPLATE_INPUT_BASENAME", snd $ FP.splitFileName inputFile) :
                    env1

        -- Return execution after doing some splicing.
        hoistEither $ do
            spec' <- traverse (splice env2) spec
            pure Execution
                { executionSpec      = spec' {specEnv = env2}
                , executionInputFile = mbInputFile
                , executionSpecPath  = specPath
                , executionSpecName  = specName
                , executionDirectory = workDirectory
                }
  where
    hoistEither :: Either MissingEnvVar a -> IO a
    hoistEither = either throwIO return


executionHeader :: Execution -> String
executionHeader execution =
    executionSpecPath execution ++
    case executionInputFile execution of
        Nothing -> ": "
        Just fp -> " (" ++ fp ++ "): "

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

data Env = Env
    { envLogger     :: !Logger
    , envDiff       :: !Bool
    , envPrettyDiff :: !Bool
    , envFix        :: !Bool
    }

data ExecutionResult = ExecutionResult
    { erExitCode :: !ExitCode
    , erStdout   :: !B.ByteString
    , erStderr   :: !B.ByteString
    } deriving (Show)

runExecution
    :: Env -> Execution -> IO ExecutionResult
runExecution env execution@Execution {..} = do
    let Spec {..} = executionSpec
    logDebug (envLogger env) [executionHeader execution ++ "running..."]

    -- Create a "CreateProcess" description.
    let createProcess = (Process.proc specCommand specArguments)
            { Process.env     = Just specEnv
            , Process.cwd     = Just executionDirectory
            , Process.std_in  = Process.CreatePipe
            , Process.std_out = Process.CreatePipe
            , Process.std_err = Process.CreatePipe
            }

    -- Actually run the process.
    logDebug (envLogger env) [executionHeader execution ++
        specCommand ++ " " ++ unwords specArguments]
    (Just hIn, Just hOut, Just hErr, hProc) <-
        Process.createProcess createProcess

    let writeStdin = (`finally` IO.hClose hIn) $ case specStdin of
            Nothing              -> pure ()
            Just (Single str)    -> IO.hPutStr hIn str
            Just (Multiple strs) -> mapM_ (IO.hPutStrLn hIn) strs
    Async.withAsync writeStdin $ \_ ->
        Async.withAsync (B.hGetContents hOut) $ \outAsync ->
        Async.withAsync (B.hGetContents hErr) $ \errAsync ->
        Async.withAsync (Process.waitForProcess hProc) $ \exitAsync -> do

        -- Get output.
        !exitCode  <- Async.wait exitAsync
        !actualOut <- Async.wait outAsync
        !actualErr <- Async.wait errAsync
        logDebug (envLogger env)
            [ executionHeader execution ++ "finished"
            , "exit code: " ++ show exitCode
            , "stdout:", show actualOut
            , "stderr:", show actualErr
            ]
        pure ExecutionResult
            { erExitCode = exitCode
            , erStdout   = actualOut
            , erStderr   = actualErr
            }

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

data AssertResult = AssertResult
    { arOk      :: Bool
    , arHeader  :: String
    , arMessage :: [String]
    } deriving (Show)

assertResultToTap :: AssertResult -> [String]
assertResultToTap ar  =
    ((if arOk ar then "ok " else "not ok ") ++ arHeader ar) :
    map ("     " ++) (concatMap lines $ arMessage ar)

-- | Check a single assertion.
runAssert
    :: Env -> Execution -> ExecutionResult -> Assert String -> IO AssertResult
runAssert env execution@Execution {..} ExecutionResult {..} assert =
    case assert of
        ExitCodeAssert expectedExitCode ->
            let actualExitCode = case erExitCode of
                    ExitSuccess   -> 0
                    ExitFailure c -> c
                success = expectedExitCode == actualExitCode in
            pure $ makeAssertResult success
                ["expected " ++ show expectedExitCode ++
                    " but got " ++ show actualExitCode | not success]

        StdoutAssert {..} -> checkAgainstFile
            (inExecutionDir stdoutFilePath) stdoutPostProcess erStdout

        StderrAssert {..} -> checkAgainstFile
            (inExecutionDir stderrFilePath) stderrPostProcess erStderr

        CreatedFileAssert {..} -> do
            let path = inExecutionDir createdFilePath
            exists <- Dir.doesFileExist path
            case exists of
                False -> pure $ makeAssertResult False
                    [createdFilePath ++ " was not created"]
                True -> case createdFileContents of
                    Nothing           -> pure $ makeAssertResult True []
                    Just expectedPath -> do
                        !actual <- readFileOrEmpty path
                        ar <- checkAgainstFile
                            (inExecutionDir expectedPath)
                            createdFilePostProcess actual
                        Dir.removeFile path
                        logDebug (envLogger env)
                            [executionHeader execution ++ "removed " ++ path]
                        pure ar

        CreatedDirectoryAssert {..} -> do
            let path = inExecutionDir createdDirectoryPath
            exists <- Dir.doesDirectoryExist path
            case exists of
                False -> pure $ makeAssertResult False
                    [createdDirectoryPath ++ " was not created"]
                True -> do
                    Dir.removeDirectoryRecursive path
                    logDebug (envLogger env)
                        [executionHeader execution ++ "removed " ++ path]
                    pure $ makeAssertResult True []
  where
    makeAssertResult ok = AssertResult ok
        (executionHeader execution ++ assertDiscriminator assert)

    inExecutionDir :: FilePath -> FilePath
    inExecutionDir fp =
        if FP.isAbsolute fp then fp else executionDirectory FP.</> fp

    checkAgainstFile
        :: FilePath -> PostProcess -> B.ByteString -> IO AssertResult
    checkAgainstFile expectedPath processor actual0 = do
        expected <- readFileOrEmpty expectedPath
        let !actual1 = postProcess processor actual0
            success = actual1 == expected
            shouldFix = envFix env && not success

            diff :: [Diff [String]] = either (const []) id $ do
                expected' <- T.unpack <$> T.decodeUtf8' expected
                actual1'  <- T.unpack <$> T.decodeUtf8' actual1
                return $
                    getGroupedDiff
                        (lines expected')
                        (lines actual1')

        when shouldFix $ B.writeFile expectedPath actual1
        pure . makeAssertResult success . concat $
            [ [ "expected:"
              , show expected
              , "actual:"
              , show actual1
              ]
            | not success && envDiff env
            ] ++
            [ [ "diff:", ppDiff diff ]
            | not success && envPrettyDiff env
            ] ++
            [ ["fixed " ++ expectedPath] | shouldFix ]


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

-- | Read a file if it exists, otherwise pretend it's empty.
readFileOrEmpty :: FilePath -> IO B.ByteString
readFileOrEmpty fp = do
    exists <- Dir.doesFileExist fp
    if exists then B.readFile fp else return B.empty

-- | Recursively finds all '.goldplate' files in bunch of files or directories.
findSpecs :: [FilePath] -> IO [FilePath]
findSpecs fps = fmap concat $ forM fps $ \fp -> do
    isDir <- Dir.doesDirectoryExist fp
    case isDir of
        False -> return [fp]
        True  -> Glob.globDir1 (Glob.compile "**/*.goldplate") fp

-- | Perform a glob match in the current directory.
--
-- This is a drop-in replacement for `glob` from the `Glob` library, which has a
-- an annoying tendency to return absolute file paths.
globCurrentDir :: String -> IO [FilePath]
globCurrentDir pattern =
    map dropLeadingDot <$> Glob.globDir1 (Glob.compile pattern) "."
  where
    dropLeadingDot fp0 = case break FP.isPathSeparator fp0 of
        (".", fp1) -> drop 1 fp1
        _          -> fp0

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

-- | Command-line options.
data Options = Options
    { oPaths      :: [FilePath]
    , oVerbose    :: Bool
    , oDiff       :: Bool
    , oPrettyDiff :: Bool
    , oFix        :: Bool
    , oJobs       :: Int
    }

defaultOptions :: Options
defaultOptions = Options
    { oPaths      = []
    , oVerbose    = False
    , oDiff       = False
    , oPrettyDiff = False
    , oFix        = False
    , oJobs       = 1
    }

parseOptions :: OA.Parser Options
parseOptions = Options
    <$> OA.some (OA.strArgument (
            OA.metavar "PATH" <>
            OA.help    "Test files/directories"))
    <*> OA.switch (
            OA.short   'v' <>
            OA.help    "Print debug info")
    <*> OA.switch (
            OA.long    "diff" <>
            OA.help    "Show differences in files")
    <*> OA.switch (
            OA.long    "pretty-diff" <>
            OA.help    "Show differences in files, output in patch format")
    <*> OA.switch (
            OA.long    "fix" <>
            OA.help    "Attempt to fix broken tests")
    <*> OA.option OA.auto (
            OA.long    "jobs" <>
            OA.short   'j' <>
            OA.value   (oJobs defaultOptions) <>
            OA.help    "Number of worker jobs")

parserInfo :: OA.ParserInfo Options
parserInfo = OA.info (OA.helper <*> versionOption <*> parseOptions) $
    OA.fullDesc <>
    OA.header goldplateVersion
  where
  versionOption = OA.infoOption goldplateVersion $
    OA.long    "version" <>
    OA.short   'V'       <>
    OA.help    "Show version info" <>
    OA.hidden
  goldplateVersion :: String
  goldplateVersion = "goldplate v" <> showVersion version

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

-- | Spawn a worker thread that takes workloads from a shared pool.
worker
    :: IORef.IORef [a]                         -- ^ Ref to a pool of work
    -> (a -> IO ())                            -- ^ Worker function
    -> IO ()
worker pool f = do
    mbWorkload <- IORef.atomicModifyIORef' pool $ \case
        []       -> ([], Nothing)
        (x : xs) -> (xs, Just x)
    case mbWorkload of
        Nothing       -> return ()
        Just workload -> f workload >> worker pool f

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

data InvalidSpec = InvalidSpec FilePath deriving (Show)

instance Exception InvalidSpec

mainWith :: Options -> IO ExitCode
mainWith options = do
    failed  <- IORef.newIORef (0 :: Int)
    env     <- Env
        <$> makeLogger (oVerbose options)
        <*> pure (oDiff options)
        <*> pure (oPrettyDiff options)
        <*> pure (oFix options)

    -- Find all specs and decode them.
    specPaths <- findSpecs (oPaths options)
    specs     <- forM specPaths $ \specPath -> do
        !errOrSpec <- A.eitherDecodeStrict <$> B.readFile specPath
        case errOrSpec of
            Right !spec -> return (specPath, spec)
            Left  !err  -> do
                logError (envLogger env)
                    [specPath ++ ": could not parse JSON: " ++ err]
                throwIO $ InvalidSpec specPath

    -- Each spec might produce a number of executions.  We can't really
    -- parallelize this because 'specExecutions' needs to change the working
    -- directory all the time and that might mess with our tests.
    let numSpecs = length specs
    logDebug (envLogger env) ["Found " ++ show numSpecs ++ " specs"]
    executions <- fmap concat $ forM specs $
        \(specPath, spec) -> specExecutions specPath spec

    -- Create a pool full of executions.
    let numJobs       = oJobs options
        numAsserts    = sum $
            map (length . specAsserts . executionSpec) executions
    logOut (envLogger env) ["1.." ++ show numAsserts]
    pool <- IORef.newIORef executions

    -- Spawn some workers to run the executions.
    Async.replicateConcurrently_ numJobs $ worker pool $ \execution -> do
        executionResult <- runExecution env execution
        forM_ (specAsserts $ executionSpec execution) $ \assert -> do
            assertResult <- runAssert env execution executionResult assert
            unless (arOk assertResult) $ IORef.atomicModifyIORef' failed $
                \x -> (x + 1, ())
            logOut (envLogger env) $ assertResultToTap assertResult

    -- Report summary.
    numFailed <- IORef.readIORef failed
    logOut (envLogger env) . pure $
        "# goldplate ran " ++ show numAsserts ++ " asserts, " ++
        (if numFailed > 0 then show numFailed ++ " failed" else "all OK")
    pure $ if numFailed > 0 then ExitFailure 1 else ExitSuccess

main :: IO ()
main = do
    options <- OA.execParser parserInfo
    mainWith options >>= exitWith