packages feed

skeletest-0.4.0: src/Skeletest/Internal/CLI.hs

{-# LANGUAGE AllowAmbiguousTypes #-}
{-# LANGUAGE CPP #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PatternSynonyms #-}
{-# LANGUAGE TypeAbstractions #-}
{-# LANGUAGE NoFieldSelectors #-}

module Skeletest.Internal.CLI (
  Flag (..),
  flag,
  IsFlag (..),
  FlagSpec (..),
  FlagType (..),
  getFlag,
  loadCliArgs,

  -- * General flags
  ANSIFlag (..),
  FormatFlag (..),
  getFormatFlag,

  -- * Internal
  parseCliArgsWith,
  FlagInfos,
  SomeFlagSpec (..),
  CLIParseResult (..),
) where

import Control.Monad (when)
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Bifunctor (first)
import Data.Dynamic (Dynamic, fromDynamic, toDyn)
import Data.Foldable (foldlM)
import Data.Foldable qualified as Seq (toList)
import Data.Foldable1 qualified as Foldable1
import Data.IORef (IORef, newIORef, readIORef, writeIORef)
import Data.List.NonEmpty qualified as NonEmpty
import Data.Map (Map)
import Data.Map qualified as Map
import Data.Maybe (fromMaybe)
import Data.Proxy (Proxy (..))
import Data.Sequence (Seq)
import Data.Sequence qualified as Seq
import Data.Text (Text)
import Data.Text qualified as Text
import Data.Typeable (TypeRep, Typeable, typeOf, typeRep)
import Skeletest.Internal.Error (invariantViolation, skeletestError)
import Skeletest.Internal.Exit (TestExitCode (..), exitWith)
import Skeletest.Internal.TestTargets (TestTargets, parseTestTargets)
import Skeletest.Internal.Utils.Color qualified as Color
import Skeletest.Internal.Utils.Term qualified as Term
import System.Environment (getArgs)
import System.IO.Unsafe (unsafePerformIO)

#if !MIN_VERSION_base(4, 20, 0)
import Data.Foldable (foldl')
#endif

-- | Register a CLI flag.
--
-- Usage:
--
-- @
-- {- MyFixture.hs -}
-- import Skeletest
--
-- newtype MyFlag = MyFlag String
-- instance IsFlag MyFlag where
--   flagName = "my-flag"
--   flagHelp = "The value for MyFixture"
--   flagSpec =
--     OptionalFlag
--       { default_ = "foo"
--       , parse = \case
--           "illegal" -> Left "invalid flag value"
--           s -> Right (MyFlag s)
--       }
--
-- instance Fixture MyFixture where
--   fixtureAction = do
--     MyFlag val <- getFlag
--     ...
--
-- {- Main.hs -}
-- import MyFixture
--
-- cliFlags =
--   [ flag @MyFlag
--   ]
-- @
data Flag = forall a. (IsFlag a) => Flag (Proxy a)

flag :: forall a. (IsFlag a) => Flag
flag = Flag (Proxy @a)

class (Typeable a) => IsFlag a where
  flagName :: String

  flagShort :: Maybe Char
  flagShort = Nothing

  -- | The placeholder for the flag to show in the help text, if
  -- the flag takes an argument.
  flagMetaVar :: String
  flagMetaVar = "VAR"

  flagHelp :: String

  flagSpec :: FlagSpec a

data FlagSpec a
  = SwitchFlag
      { fromBool :: Bool -> a
      }
  | RequiredFlag
      { parse :: String -> Either String a
      }
  | OptionalFlag
      { default_ :: a
      , parse :: String -> Either String a
      }
  | forall x.
    MultiFlag
      { type_ :: FlagType x
      , parseMulti :: [x] -> Either String a
      }

data FlagType x where
  FlagType_Switch :: FlagType Bool
  FlagType_Arg :: FlagType String

getFlag :: forall a m. (MonadIO m, IsFlag a) => m a
getFlag =
  liftIO $
    lookupCliFlag rep >>= \case
      Just dyn ->
        case fromDynamic dyn of
          Just a -> pure a
          Nothing ->
            invariantViolation . unwords $
              [ "CLI flag store contained incorrect types."
              , "Expected: " <> show rep <> "."
              , "Got: " <> show dyn
              ]
      Nothing ->
        skeletestError . Text.unwords $
          [ "CLI flag '" <> Text.pack (flagName @a) <> "' was not registered."
          , "Did you add it to cliFlags in Main.hs?"
          ]
 where
  rep = typeRep (Proxy @a)

{----- General flags -----}

newtype ANSIFlag = ANSIFlag (Maybe Bool)
instance IsFlag ANSIFlag where
  flagName = "ansi"
  flagHelp = "Whether to enable ANSI output: auto (default), always, never"
  flagSpec =
    OptionalFlag
      { default_ = ANSIFlag Nothing
      , parse = \case
          "auto" -> Right . ANSIFlag $ Nothing
          "always" -> Right . ANSIFlag $ Just True
          "never" -> Right . ANSIFlag $ Just False
          s -> Left $ "invalid value: " <> s
      }

data FormatFlag
  = FormatFlag_Minimal
  | FormatFlag_Full
  | FormatFlag_Verbose
  deriving (Show, Eq)

instance IsFlag (Maybe FormatFlag) where
  flagName = "format"
  flagHelp = "The format of the output"
  flagSpec =
    OptionalFlag
      { default_ = Nothing
      , parse = \case
          "minimal" -> Right $ Just FormatFlag_Minimal
          "full" -> Right $ Just FormatFlag_Full
          "verbose" -> Right $ Just FormatFlag_Verbose
          s -> Left $ "Unknown format: " <> s
      }

getFormatFlag :: IO FormatFlag
getFormatFlag = getFlag >>= maybe getDefault pure
 where
  getDefault = do
    supportsANSI <- Term.supportsANSI Term.stdout
    pure $
      if supportsANSI
        then FormatFlag_Minimal
        else FormatFlag_Full

{----- Load CLI arguments -----}

-- | Parse the CLI arguments using the given user-defined flags, then
-- stores the flags in the global state and returns the positional
-- arguments.
loadCliArgs :: [Flag] -> [Flag] -> IO TestTargets
loadCliArgs builtinFlags flags = do
  args0 <- getArgs
  case parseCliArgs (builtinFlags <> flags) args0 of
    CLISetupFailure msg -> do
      Term.outputErr $ Color.red $ "ERROR: " <> msg
      exitWith ExitCLIFailure
    CLIHelpRequested -> do
      Term.output helpText
      exitWith ExitSuccess
    CLIParseFailure msg -> do
      Term.outputErr $ msg <> "\n\n" <> helpText
      exitWith ExitCLIFailure
    CLIParseSuccess{testTargets, flagStore} -> do
      setCliFlagStore flagStore
      pure testTargets
 where
  helpText = getHelpText builtinFlags flags

getHelpText :: [Flag] -> [Flag] -> Text
getHelpText builtinFlags customFlags =
  Text.intercalate "\n\n" $
    "Usage: skeletest [OPTIONS] [--] [TARGETS]" : map (uncurry renderSection) helpSections
 where
  helpSections =
    filter (not . Text.null . snd) $
      [ ("TEST SELECTION", testSelectionDocs)
      , ("BUILTIN OPTIONS", renderFlagList builtinFlagDocs)
      , ("CUSTOM OPTIONS", renderFlagList customFlagDocs)
      ]

  testSelectionDocs =
    Text.intercalate "\n" $
      [ "Test targets may be specified as plain positional arguments, with the following syntax:"
      , "    * Tests including substring:      '[myFooFunc]'"
      , "    * Tests tagged with marker:       '@fast'"
      , "    * Tests in file, relative to CWD: 'test/MyLib/FooSpec.hs'"
      , "    * Tests matching pattern in file: 'test/MyLib/FooSpec.hs[myFooFunc]'"
      , "        * Syntax sugar for '(test/MyLib/FooSpec.hs and [myFooFunc])'"
      , "    * Tests matching both targets:    '[func1] and [func2]'"
      , "    * Tests matching either target:   '[func1] or [func2]'"
      , "    * Tests not matching target:      'not [func1]'"
      , ""
      , "More examples:"
      , "    * 'test/MySpec.hs and ([myFooFunc] or [myBarFunc]) and @fast'"
      , "    * '[myFooFunc] or test/MySpec.hs[myBarFunc]'"
      , ""
      , "When multiple targets are specified, they are joined with 'or'."
      ]

  builtinFlagDocs = ("help", Just 'h', Nothing, "Display this help text") : fromFlags builtinFlags
  customFlagDocs = fromFlags customFlags
  fromFlags flags =
    [ (Text.pack (flagName @a), flagShort @a, mMetaVar, Text.pack (flagHelp @a))
    | Flag (Proxy :: Proxy a) <- flags
    , let mMetaVar =
            case flagSpec @a of
              SwitchFlag{} -> Nothing
              RequiredFlag{} -> Just $ Text.pack (flagMetaVar @a)
              OptionalFlag{} -> Just $ Text.pack (flagMetaVar @a)
              MultiFlag{} -> Just $ Text.pack (flagMetaVar @a)
    ]

  renderSection title body =
    Text.intercalate "\n" $
      [ "===== " <> title
      , ""
      , body
      ]

  renderFlagList flagList =
    Text.intercalate "\n" . mkTabular $
      [ (shortName <> renderLongFlag longName <> metaVar, help)
      | (longName, mShortName, mMetaVar, help) <- flagList
      , let
          shortName =
            case mShortName of
              Just short -> renderShortFlag short <> ", "
              Nothing -> ""
          metaVar =
            case mMetaVar of
              Just meta -> " <" <> meta <> ">"
              Nothing -> ""
      ]

  mkTabular rows0 =
    case NonEmpty.nonEmpty rows0 of
      Nothing -> []
      Just rows ->
        let fstColWidth = Foldable1.maximum $ NonEmpty.map (Text.length . fst) rows
            margin = 2 -- space between columns
         in [ a <> Text.replicate (fstColWidth - Text.length a + margin) " " <> b
            | (a, b) <- NonEmpty.toList rows
            ]

{----- Parse args -----}

data CLIParseResult
  = CLISetupFailure Text
  | CLIHelpRequested
  | CLIParseFailure Text
  | CLIParseSuccess
      { testTargets :: TestTargets
      , flagStore :: CLIFlagStore
      }

type FlagInfos = [(Text, Maybe Char, SomeFlagSpec)]
data SomeFlagSpec = forall a. (Typeable a) => SomeFlagSpec (FlagSpec a)

parseCliArgs :: [Flag] -> [String] -> CLIParseResult
parseCliArgs flags args = either id id $ do
  flagInfos <- getFlagInfos flags
  pure $ parseCliArgsWith flagInfos args

getFlagInfos :: [Flag] -> Either CLIParseResult FlagInfos
getFlagInfos flags = do
  let infos = map fromFlag flags
  checkDups [renderLongFlag name | (name, _, _) <- infos]
  checkDups [renderShortFlag c | (_, Just c, _) <- infos]
  pure infos
 where
  fromFlag (Flag (Proxy @a)) =
    let name = Text.pack $ flagName @a
        spec = SomeFlagSpec (flagSpec @a)
     in (name, flagShort @a, spec)

  checkDups vals =
    case Map.keys . Map.filter (> 1) . Map.fromListWith (+) . map (,1 :: Int) $ vals of
      [] -> pure ()
      dup : _ -> Left . CLISetupFailure $ "Flag registered multiple times: " <> dup

parseCliArgsWith :: FlagInfos -> [String] -> CLIParseResult
parseCliArgsWith flagInfos args = either id id $ do
  -- quick sweep for --help/-h; skip parsing flags if so
  when (any (`elem` ["--help", "-h"]) args) $ Left CLIHelpRequested

  (flagVals, args') <- collectCLIArgs flagInfos $ map Text.pack args
  testTargets <- first CLIParseFailure $ parseTestTargets args'
  flagStore <- parseCLIFlags flagInfos flagVals
  pure CLIParseSuccess{testTargets, flagStore}

collectCLIArgs ::
  FlagInfos ->
  [Text] ->
  Either CLIParseResult (Map Text [Text], [Text])
collectCLIArgs flagInfos args0 = first CLIParseFailure $ do
  (flagVals, posArgs) <- go Map.empty Seq.empty args0
  pure (Map.map Seq.toList flagVals, Seq.toList posArgs)
 where
  go :: Map Text (Seq Text) -> Seq Text -> [Text] -> Either Text (Map Text (Seq Text), Seq Text)
  go flagVals posArgs = \case
    [] -> Right (flagVals, posArgs)
    "--" : rest -> Right (flagVals, posArgs <> Seq.fromList rest)
    curr : rest
      | Just rawFlag <- Text.stripPrefix "--" curr -> do
          (name, arg, rest') <- parseLongFlag rawFlag rest
          go (addFlag flagVals (name, arg)) posArgs rest'
      | Just rawFlag <- Text.stripPrefix "-" curr -> do
          (args, rest') <- parseShortFlags rawFlag rest
          let flagVals' = foldl' addFlag flagVals args
          go flagVals' posArgs rest'
      | otherwise -> do
          go flagVals (posArgs Seq.|> curr) rest

  longFlags = Map.fromList [(name, spec) | (name, _, spec) <- flagInfos]
  parseLongFlag rawFlag rest = do
    let (name, mArg) =
          case Text.breakOn "=" rawFlag of
            (_, "") -> (rawFlag, Nothing)
            (n, post) -> (n, Just $ Text.drop 1 post)
        flagDisp = renderLongFlag name
    SomeFlagSpec spec <-
      maybe (Left $ "Unknown flag: " <> flagDisp) pure $
        Map.lookup name longFlags
    (arg, rest') <- validateArg spec flagDisp mArg rest
    pure (name, arg, rest')

  shortFlags = Map.fromList [(c, (name, spec)) | (name, Just c, spec) <- flagInfos]
  parseShortFlags rawFlag rest = do
    (char, rawFlag') <-
      maybe (Left "Invalid flag: -") pure $
        Text.uncons rawFlag
    let flagDisp = renderShortFlag char
    (name, SomeFlagSpec spec) <-
      maybe (Left $ "Unknown flag: " <> flagDisp) pure $
        Map.lookup char shortFlags
    let mArg =
          if (not . Text.null) rawFlag' && expectsArg spec
            then Just rawFlag'
            else Nothing
        parseNext =
          if (not . Text.null) rawFlag' && (not . expectsArg) spec
            then parseShortFlags rawFlag'
            else \rest' -> pure ([], rest')
    (arg, rest') <- validateArg spec flagDisp mArg rest
    (args, rest'') <- parseNext rest'
    pure ((name, arg) : args, rest'')

  validateArg spec flagDisp mArg rest = do
    if expectsArg spec
      then case (mArg, rest) of
        (Just arg, _) -> pure (arg, rest)
        (Nothing, arg : rest') -> pure (arg, rest')
        (Nothing, []) -> Left $ "Flag '" <> flagDisp <> "' requires argument"
      else case (mArg, rest) of
        (Just arg, _) -> Left $ "Flag '" <> flagDisp <> "' does not take arguments, got: " <> arg
        _ -> pure ("", rest)

  expectsArg :: FlagSpec a -> Bool
  expectsArg = \case
    SwitchFlag{} -> False
    RequiredFlag{} -> True
    OptionalFlag{} -> True
    MultiFlag{type_} ->
      case type_ of
        FlagType_Switch -> False
        FlagType_Arg -> True

  addFlag :: Map Text (Seq Text) -> (Text, Text) -> Map Text (Seq Text)
  addFlag flagVals (name, arg) =
    Map.alter
      (Just . (Seq.|> arg) . fromMaybe Seq.empty)
      name
      flagVals

parseCLIFlags :: FlagInfos -> Map Text [Text] -> Either CLIParseResult CLIFlagStore
parseCLIFlags flagInfos flagVals = first CLIParseFailure $ foldlM go Map.empty flagInfos
 where
  go flagStore (name, _, SomeFlagSpec spec0) = do
    let vals = Map.findWithDefault [] name flagVals
    val <- first Text.pack . parse name spec0 . map Text.unpack $ vals
    pure $ insertFlagStore val flagStore

  parse :: Text -> FlagSpec a -> [String] -> Either String a
  parse name spec0 vals =
    case spec0 of
      spec@SwitchFlag{} -> do
        pure (spec.fromBool $ (not . null) vals)
      spec@RequiredFlag{} -> do
        val <- maybe (throwRequired name) pure $ getLast vals
        spec.parse val
      spec@OptionalFlag{} -> do
        case getLast vals of
          Nothing -> pure spec.default_
          Just val -> spec.parse val
      MultiFlag{type_, parseMulti} -> do
        parseMulti $
          case type_ of
            FlagType_Switch -> True <$ vals
            FlagType_Arg -> vals

  throwRequired name = Left $ "Flag '" <> (Text.unpack . renderLongFlag) name <> "' is required"
  getLast = fmap NonEmpty.last . NonEmpty.nonEmpty

renderLongFlag :: Text -> Text
renderLongFlag = ("--" <>)

renderShortFlag :: Char -> Text
renderShortFlag c = Text.pack ['-', c]

{----- CLIFlagStore -----}

type CLIFlagStore = Map TypeRep Dynamic

insertFlagStore :: (Typeable a) => a -> CLIFlagStore -> CLIFlagStore
insertFlagStore x = Map.insert (typeOf x) (toDyn x)

cliFlagStoreRef :: IORef CLIFlagStore
cliFlagStoreRef = unsafePerformIO $ newIORef Map.empty
{-# NOINLINE cliFlagStoreRef #-}

setCliFlagStore :: CLIFlagStore -> IO ()
setCliFlagStore = writeIORef cliFlagStoreRef

lookupCliFlag :: TypeRep -> IO (Maybe Dynamic)
lookupCliFlag rep = Map.lookup rep <$> readIORef cliFlagStoreRef