tasty-discover-5.1.0: src/Test/Tasty/Discover.hs
{-# LANGUAGE FlexibleInstances #-}
{-# LANGUAGE InstanceSigs #-}
module Test.Tasty.Discover
( Tasty(..)
, TastyInfo
, SkipTest(..)
, Flavored(..)
, flavored
, name
, description
, nameOf
, descriptionOf
, skip
, platform
, evaluatePlatformExpression
) where
import Data.Maybe
import Data.Monoid
import System.Info (os)
import Test.Tasty.Discover.TastyInfo (TastyInfo)
import Test.Tasty.Discover.Internal.Config (SkipTest(..))
import qualified Test.Tasty as TT
import qualified Test.Tasty.Discover.TastyInfo as TI
{- $skipPlatform
Guidelines for using 'skip' and 'platform'
-----------------------------------------
TL;DR:
- For tests exposed via @tasty_@ functions, prefer using the 'Flavored' pattern to apply
transformations like 'skip' and 'platform' so they take effect at the TestTree level.
- Directly applying 'skip' to an already-constructed 'TT.TestTree' marks the subtree as
skipped (the test can observe 'SkipTest' via 'TT.askOption'), but the outer 'Tasty'
instance may not replace it with a top-level "[SKIPPED]" node.
Patterns:
- Skip with 'Flavored':
@
tasty_mySkipped :: Flavored TT.TestTree
tasty_mySkipped = flavored skip $ TT.testCase "will be skipped" $ pure ()
@
-
@
tasty_linuxOnly :: Flavored TT.TestTree
tasty_linuxOnly = flavored (platform "linux") $ TT.testCase "Linux only" $ pure ()
@
Platform expressions:
- Names: @"linux"@, @"darwin"@, @"windows"@ (mapped to @"mingw32"@), @"mingw32"@, and @"unix"@ (matches linux|darwin)
- Operators: NOT @!@, AND @&@, OR @|@
-- Examples:
@
platform "!windows & !darwin" -- neither Windows nor Darwin
platform "linux | darwin" -- Linux or Darwin
platform "unix" -- Linux or Darwin
@
Combining:
- You can compose transformations: e.g., @flavored (platform "linux") . flavored skip@
or wrap once with a composed function @flavored (platform "linux" . skip)@.
See 'skip' and 'platform' for function-specific details.
-}
class Tasty a where
tasty :: TastyInfo -> a -> IO TT.TestTree
instance Tasty TT.TestTree where
tasty _ a = pure a
instance Tasty [TT.TestTree] where
tasty info a = pure $ TT.testGroup (descriptionOf info) a
instance Tasty (IO TT.TestTree) where
tasty _ a = a
instance Tasty (IO [TT.TestTree]) where
tasty info a = TT.testGroup (descriptionOf info) <$> a
-- | A general-purpose wrapper for transforming TestTrees generated by tasty_ functions.
--
-- The Flavored type allows you to apply transformations to test trees before they
-- are added to the test suite. This enables applying various options and modifications
-- such as skipping tests, setting timeouts, adding metadata, grouping, etc.
--
-- Example usage:
-- @
-- -- Skip a test
-- tasty_skipThis :: Flavored Property
-- tasty_skipThis = flavored skip $ property $ do
-- -- This test will be skipped
-- H.failure
-- @
data Flavored a = Flavored
{ flavoring :: TT.TestTree -> TT.TestTree -- ^ Transformation function to apply
, unFlavored :: a -- ^ The wrapped test value
}
-- | Create a Flavored wrapper with a specific transformation function.
--
-- @flavored f a@ applies transformation @f@ to the TestTree generated from @a@.
flavored :: (TT.TestTree -> TT.TestTree) -> a -> Flavored a
flavored f a = Flavored f a
instance Tasty a => Tasty (Flavored a) where
tasty :: TastyInfo -> Flavored a -> IO TT.TestTree
tasty info (Flavored f a) = do
testTree <- tasty info a
pure $ f testTree
nameOf :: TastyInfo -> String
nameOf info =
fromMaybe "<unnamed>" (getLast (TI.name info))
descriptionOf :: TastyInfo -> String
descriptionOf info =
fromMaybe "<undescribed>" (getLast (TI.description info))
name :: String -> TastyInfo
name n = mempty
{ TI.name = Last $ Just n
}
description :: String -> TastyInfo
description n = mempty
{ TI.description = Last $ Just n
}
-- | Mark a test tree to be skipped by setting the SkipTest option to True.
--
-- Usage guidelines: see the @Guidelines for using 'skip' and 'platform'@ section ('skipPlatform').
-- In short, for @tasty_@ tests prefer 'flavored' 'skip' to let the outer 'Tasty' instance
-- short-circuit at the TestTree level. Direct 'skip' on a pre-built tree applies the option
-- to the subtree; the test can still observe 'SkipTest' via 'TT.askOption'.
--
-- Examples:
-- @
-- -- Direct usage on a TestTree (the test can read SkipTest via askOption)
-- test_directSkip :: TestTree
-- test_directSkip = skip $ testCase "will be skipped" $ pure ()
--
-- -- Preferred for tasty_ tests: apply at the right stage using Flavored
-- tasty_skipProperty :: Flavored Property
-- tasty_skipProperty = flavored skip $ property $ do
-- -- This property will be skipped
-- H.failure
-- @
skip :: TT.TestTree -> TT.TestTree
skip = TT.adjustOption (const (SkipTest True))
-- | Conditionally run a test based on a platform expression.
--
-- Usage guidelines, syntax, and examples: see the @Guidelines for using 'skip' and 'platform'@
-- section ('skipPlatform').
--
-- The expression supports logical operations with platform names:
-- - Platform names: "linux", "darwin", "mingw32", "windows", "unix"
-- - Negation: "!platform" (not on platform)
-- - Conjunction: "platform1 & platform2" (on both platforms)
-- - Disjunction: "platform1 | platform2" (on either platform)
-- - Parentheses: "(platform1 | platform2) & !platform3"
--
-- Examples:
-- @
-- -- Only on Linux
-- test_linuxOnly :: TestTree
-- test_linuxOnly = platform "linux" $ testCase "Linux only" $ pure ()
--
-- -- Not on Windows or macOS
-- test_notWinMac :: TestTree
-- test_notWinMac = platform "!windows & !darwin" $ testCase "Unix-like only" $ pure ()
--
-- -- On Linux or macOS but not Windows
-- test_unixLike :: TestTree
-- test_unixLike = platform "(linux | darwin) & !windows" $ testCase "Unix-like" $ pure ()
-- @
platform :: String -> TT.TestTree -> TT.TestTree
platform expr testTree =
if evaluatePlatformExpression expr os
then testTree
else skip testTree
-- | Evaluate a platform expression against a given platform string.
--
-- Inputs:
-- - The first argument is the platform expression (e.g. @"linux | darwin"@, @"!windows"@).
-- - The second argument is the current platform, typically @System.Info.os@ (e.g. @"linux"@, @"darwin"@, @"mingw32"@).
--
-- Semantics (result is 'True' when the test should run):
-- - Supported platform names: @"linux"@, @"darwin"@, @"mingw32"@, @"windows"@ (alias for @"mingw32"@), and @"unix"@ (alias for @"linux | darwin"@).
-- - Supported operators: NOT @!@, AND @&@, OR @|@.
-- - Unknown simple names evaluate to 'False' (do not run).
-- - Malformed or empty expressions evaluate to 'True' (default to running).
-- Malformed includes the presence of operator characters without a valid parse.
-- - Parentheses characters @(@ and @)@ are tokenized but grouping is not currently implemented;
-- using parentheses in the expression will cause it to be treated as malformed and therefore
-- default to 'True' (run). Prefer composing with @&@ and @|@ without parentheses.
--
-- Examples:
--
-- @
-- evaluatePlatformExpression "linux" "linux" == True
-- evaluatePlatformExpression "linux" "darwin" == False
-- evaluatePlatformExpression "!windows" "mingw32" == False
-- evaluatePlatformExpression "linux|darwin" "darwin" == True
-- evaluatePlatformExpression "unix" "darwin" == True -- alias for linux|darwin
-- evaluatePlatformExpression "unknown" "linux" == False -- unknown simple name
-- evaluatePlatformExpression "" "linux" == True -- empty -> run
-- @
evaluatePlatformExpression :: String -> String -> Bool
evaluatePlatformExpression expr currentPlatform =
case parsePlatformExpression expr of
Just result -> evalExpression result currentPlatform
Nothing ->
-- If it's just a simple unknown platform name, return False
-- If it's an empty/malformed expression, return True
let malformedOrEmpty = null (words expr) || any (`elem` ['&', '|', '!', '(', ')']) expr
in malformedOrEmpty
-- Parse a platform expression with logical operators
parsePlatformExpression :: String -> Maybe PlatformExpr
parsePlatformExpression expr = parseOr (tokenize expr)
-- Tokenize the expression preserving logical operators
tokenize :: String -> [String]
tokenize = words . concatMap tokenizeChar
where
tokenizeChar '&' = " & "
tokenizeChar '|' = " | "
tokenizeChar '(' = " ( "
tokenizeChar ')' = " ) "
tokenizeChar c = [c]
-- Parse OR expressions (lowest precedence)
parseOr :: [String] -> Maybe PlatformExpr
parseOr tokens = case break (== "|") tokens of
(left, []) -> parseAnd left
(left, _:right) -> do
leftExpr <- parseAnd left
rightExpr <- parseOr right
return $ Or leftExpr rightExpr
-- Parse AND expressions (higher precedence)
parseAnd :: [String] -> Maybe PlatformExpr
parseAnd tokens = case break (== "&") tokens of
(left, []) -> parseAtom left
(left, _:right) -> do
leftExpr <- parseAtom left
rightExpr <- parseAnd right
return $ And leftExpr rightExpr
-- Parse atomic expressions (platform names and negation)
parseAtom :: [String] -> Maybe PlatformExpr
parseAtom [] = Nothing
parseAtom tokens = case tokens of
["linux"] -> Just $ PlatformName "linux"
["darwin"] -> Just $ PlatformName "darwin"
["windows"] -> Just $ PlatformName "mingw32"
["mingw32"] -> Just $ PlatformName "mingw32"
["unix"] -> Just $ Or (PlatformName "linux") (PlatformName "darwin")
["!linux"] -> Just $ Not (PlatformName "linux")
["!darwin"] -> Just $ Not (PlatformName "darwin")
["!windows"] -> Just $ Not (PlatformName "mingw32")
["!mingw32"] -> Just $ Not (PlatformName "mingw32")
["!unix"] -> Just $ Not (Or (PlatformName "linux") (PlatformName "darwin"))
_ -> Nothing
-- Simple expression data type
data PlatformExpr
= PlatformName String
| Not PlatformExpr
| And PlatformExpr PlatformExpr
| Or PlatformExpr PlatformExpr
deriving stock (Show, Eq)
-- Evaluate the expression against the current platform
evalExpression :: PlatformExpr -> String -> Bool
evalExpression expr currentPlatform = case expr of
PlatformName platformName -> currentPlatform == platformName
Not e -> not (evalExpression e currentPlatform)
And e1 e2 -> evalExpression e1 currentPlatform && evalExpression e2 currentPlatform
Or e1 e2 -> evalExpression e1 currentPlatform || evalExpression e2 currentPlatform