packages feed

test-karya-0.0.3: src/EL/Test/TestKaryaGenerate.hs

{-# LANGUAGE OverloadedStrings #-}
{- | Collect tests from the given modules and generate a haskell module that
    calls the tests.  Test functions are any function starting with @test_@ or
    @profile_@ and immediately followed by @=@ (implying the function has no
    arguments).  This module doesn't distinguish between tests and profiles,
    but they should presumably be compiled separately since they require
    different flags.

    If a module has a function called @initialize@, it will be called as
    @IO ()@ prior to the tests.
-}
module EL.Test.TestKaryaGenerate (main) where
import qualified Data.Char as Char
import qualified Data.List as List
import qualified Data.Map as Map
import qualified Data.Text as Text
import qualified Data.Text.IO as Text.IO

import qualified System.Directory as Directory
import qualified System.Environment
import qualified System.FilePath as FilePath
import qualified System.IO as IO

import qualified EL.Private.ExtractHs as ExtractHs
import qualified EL.Private.Regex as Regex
import Global


-- pgmF args: OrigSourceName.hs input.hs output.hs
main :: IO ()
main = do
    args <- System.Environment.getArgs
    (baseDir, outputFile, defaultArgs) <- case args of
        origFile : _inputFile : outputFile : defaultArgs -> return
            ( FilePath.takeDirectory origFile
            , outputFile
            , if null defaultArgs then defaultDefaultArgs else defaultArgs
            )
        _ -> error "expected: origFile inputFile outputFile"
    inputFiles <- find (Char.isUpper . head) ("_test.hs" `List.isSuffixOf`)
        baseDir
    extracted <- ExtractHs.extractFiles extract inputFiles
    let (warnings, output) = generate defaultArgs $
            Map.mapKeys (stripPrefix baseDir) extracted
    mapM_ (Text.IO.hPutStrLn IO.stderr) warnings
    progName <- System.Environment.getProgName
    Text.IO.writeFile outputFile $ header progName <> output

defaultDefaultArgs :: [String]
defaultDefaultArgs =
    [ "--jobs=auto"
    , "--clear-dirs"
    , "--output=dist/test-output"
    , "--check-output"
    , "."
    ]

header :: String -> Text
header program = Text.unlines
    [ "-- automatically generated by " <> txt program
    , "{-# LANGUAGE OverloadedStrings #-}"
    ]

-- | Recursively find files below a directory.
find :: (FilePath -> Bool) -> (FilePath -> Bool) -> FilePath -> IO [FilePath]
find wantDir wantFile = fmap concat . go
    where
    go dir = do
        (dirs, fns) <- partitionM Directory.doesDirectoryExist =<< list dir
        rest <- mapM go (filter (wantDir . FilePath.takeFileName) dirs)
        return $ filter wantFile fns : concat rest

list :: FilePath -> IO [FilePath]
list dir = map (dir FilePath.</>) <$> Directory.listDirectory dir

partitionM :: Monad m => (a -> m Bool) -> [a] -> m ([a], [a])
partitionM fp = go
    where
    go (x:xs) = fp x >>= \b -> case b of
        True -> (\(as, bs) -> (x:as, bs)) <$> go xs
        False -> (\(as, bs) -> (as, x:bs)) <$> go xs
    go [] = return ([], [])

stripPrefix :: FilePath -> FilePath -> FilePath
stripPrefix prefix fname = dropWhile (=='/') $
    if prefix `List.isPrefixOf` fname
        then drop (length prefix) fname else fname


-- * generate

type Warning = Text

generate :: [String] -> Map FilePath ([Test], HasMeta) -> ([Warning], Text)
generate defaultArgs extracted = (,) warnings $
    testTemplate
        (Text.unlines $ map ExtractHs.makeImport (Map.keys fnameTests))
        (Text.intercalate "\n    , " $ makeTests fnameTests)
        defaultArgs
    where
    (empty, fnameTests) = Map.partition (null . fst) extracted
    warnings = map (("Warning: no (test|profile)_* defs in " <>) . txt)
        (Map.keys empty)

testTemplate :: Text -> Text -> [String] -> Text
testTemplate imports allTests defaultArgs =
    "import qualified EL.Test.RunTests as RunTests\n\
    \import EL.Test.RunTests (Test(..))\n\
    \\n"
    <> imports <> "\n\
    \\n\
    \tests :: [Test]\n\
    \tests = \n\
    \    [ " <> allTests <> "\n\
    \    ]\n\
    \\n\
    \main :: IO ()\n\
    \main = RunTests.run " <> showt defaultArgs <> " tests\n"

data Test  = Test {
    testLineNumber :: !LineNumber
    , testName :: !Text
    } deriving (Show)
type LineNumber = Int


-- * extract

type HasMeta = Maybe Text

-- | Extract test functions and possible metadata from the file.
extract :: Text -> ([Test], HasMeta)
extract content = (extractTests content, hasMeta content)

hasMeta :: Text -> HasMeta
hasMeta =
    (\b -> if b then Just "meta" else Nothing) . Regex.matches reg
    where reg = Regex.compileOptionsUnsafe [Regex.Multiline] "^meta\\b"

extractTests :: Text -> [Test]
extractTests = go . zip [1..] . Text.lines
    where
    go ((i, line) : lines)
        | Just def <- hasTestFunction line = Test
            { testLineNumber = i
            , testName = def
            } : go rest
        | otherwise = go lines
        where
        rest = dropWhile (isIndented . snd) lines
        -- TODO does this get fooled by empty lines?
        isIndented t = " " `Text.isPrefixOf` t || t == "\n"
    go [] = []

hasTestFunction :: Text -> Maybe Text
hasTestFunction line
    | Regex.matches reg line = Just $ head (Text.words line)
    | otherwise = Nothing
    where
    reg = Regex.compileUnsafe "^(?:test|profile)_[a-zA-Z0-9_]+ \\="

-- * make

makeTests :: Map.Map FilePath ([Test], HasMeta) -> [Text]
makeTests fnameTests =
    [ makeTestLine fname test meta
    | (fname, (tests, meta)) <- Map.toList fnameTests, test <- tests
    ]

makeTestLine :: FilePath -> Test -> HasMeta -> Text
makeTestLine fname test meta = Text.unwords
    [ "Test", showt name, "(" <> name <> " >> return ())"
    , showt fname, showt (testLineNumber test)
    , case meta of
        Nothing -> "Nothing"
        Just fn -> "(Just " <> ExtractHs.pathToModule fname <> "." <> fn <> ")"
    ]
    where name = ExtractHs.pathToModule fname <> "." <> testName test