packages feed

morley-1.14.0: doctests/Main.hs

-- SPDX-FileCopyrightText: 2020 Tocqueville Group
--
-- SPDX-License-Identifier: LicenseRef-MIT-TQ

module Main
  ( main
  ) where

import qualified Data.ByteString as BS
import Data.Char (isSpace)
import qualified Data.List as List
import Distribution.Fields (runParseResult, showPError)
import Distribution.PackageDescription
  (BuildInfo(defaultExtensions, targetBuildDepends), CondTree(condTreeData),
  GenericPackageDescription(condLibrary), Library(libBuildInfo))
import Distribution.PackageDescription.Parsec (parseGenericPackageDescription)
import Distribution.Simple (Extension(EnableExtension), depPkgName, unPackageName)
import Options.Applicative
  (ParserInfo, execParser, flag', fullDesc, help, helper, info, long, metavar, progDesc, short,
  showDefault, strArgument, strOption, value)
import System.Environment (lookupEnv)
import System.Process (readProcess)
import Test.DocTest (doctest)

-- | Runs 'doctest' on the 'morley' package.
--
-- To run via stack, simply run:
--
-- > stack test morley:test:doctests
--
-- Otherwise, you'll need to specify 3 things:
--
-- * The location of the GHC's package database
-- * The location of morley's source code
-- * The location of morley's cabal file
--
-- > ./doctests --cabal-file code/morley/morley.cabal -- -package-db=<pkg-db-dir> -i code/morley/src
--
-- Any other arguments passed to this program will be forwarded to @doctest@.
main :: IO ()
main = do
  opts <- execParser parserInfo
  doctestArgs <- getDoctestArgs opts

  log opts "Running doctest with args:"
  traverse_ (log opts . mappend "  ") doctestArgs

  putTextLn "Running doctest."
  doctest doctestArgs

data Options = Options
  { cabalFile :: FilePath
  , verbosity :: Maybe Int
  , otherArgs :: [String]
  }
  deriving stock Show

parserInfo :: ParserInfo Options
parserInfo =
  info
    (optionsParser <**> helper)
    (mconcat
      [ fullDesc
      , progDesc "Run doctests. All arguments passed after '--' will be passed down to doctest and GHC."
      ]
    )
  where
    optionsParser =
      Options
      <$> strOption
          (mconcat
            [ long "cabal-file"
            , value "morley.cabal"
            , showDefault
            , metavar "FILEPATH"
            , help "Filepath for the .cabal file"
            ]
          )
      <*> (fmap (fmap length) . optional . some . flag' () . mconcat $
            [ short 'V'
            , help "Increase verbosity (pass up to 5 times to increase further, e.g. '-VVV').\
                   \ This flag will be passed down to doctest and GHC."
            ]
          )
      <*> many (strArgument (metavar "ARGS..." ))

-- | Get all the arguments needed to run `doctest`.
getDoctestArgs :: Options -> IO [String]
getDoctestArgs opts = do
  lib <- getCabalLibComponent opts
  argsFromStack <- getArgsFromStack opts

  pure $
    [ "-package-env=-"
    , "-hide-all-packages"
    ]
    <> maybeToList (getGHCVerbosityFlag opts)
    <> argsFromStack
    <> (getDependencies lib <&> \pkg -> "-package=" <> pkg)
    <> (getEnabledExtensions lib <&> \ext -> "-X" <> ext)
    <> otherArgs opts

-- | If this program is running via @stack@, then we can:
--
-- * guess where the source code is located
-- * use @stack@ to find out where the package database is located
--
-- Otherwise, these arguments will have to be specified by the user, via the
-- @-package-db@ and @-i@ flags.
getArgsFromStack :: Options -> IO [String]
getArgsFromStack opts = do
  -- If 'STACK_EXE' is set, we assume we're running via 'stack' and not,
  -- for example, via cabal or haskell.nix.
  lookupEnv "STACK_EXE" >>= \case
    Nothing ->  do
      log opts "The environment variable 'STACK_EXE' is not set."
      pure []
    Just stackBin -> do
      log opts $ "The environment variable 'STACK_EXE' is set: " <> stackBin
      log opts "Assuming we're running via stack."
      snapshotPkgDb <- readProcess stackBin ["path", "--snapshot-pkg-db"] []
      localPkgDb <- readProcess stackBin ["path", "--local-pkg-db"] []
      pure
        [ "-package-db=" <> List.dropWhileEnd isSpace snapshotPkgDb
        , "-package-db=" <> List.dropWhileEnd isSpace localPkgDb
        , "-i", "src"
        ]

-- | Parse a cabal file and extract info about its "library" component.
getCabalLibComponent :: Options -> IO Library
getCabalLibComponent opts = do
  let cabalFilePath = cabalFile opts
  log opts $ "Reading cabal file:" <> cabalFilePath
  contents <- BS.readFile cabalFilePath
  case snd <$> runParseResult $ parseGenericPackageDescription contents of
    Left (version, err :| errs) -> do
      die $ toString $ List.unlines $
        [ "Failed to parse .cabal file: " <> cabalFilePath
        , "Version: " <> show version
        , "Errors:"
        ]
        <> (showPError cabalFilePath <$> (err : errs))
    Right cabal -> do
      case condLibrary cabal of
        Nothing -> die "Cabal file did not contain a library component."
        Just condTreeLib -> pure $ condTreeData condTreeLib

getEnabledExtensions :: Library -> [String]
getEnabledExtensions lib =
  libBuildInfo lib & defaultExtensions & mapMaybe \case
    EnableExtension extension -> Just $ show extension
    _ -> Nothing

getDependencies :: Library -> [String]
getDependencies lib =
  libBuildInfo lib & targetBuildDepends & fmap (unPackageName . depPkgName)

-- | Due to a limitation in optparse-applicative, our @-V@ flag has a
-- different syntax than GHC's @-v@ flag.
--
-- Our @-VVV@ corresponds to @-v3@ in GHC.
--
-- This function transforms our syntax into GHC's.
getGHCVerbosityFlag :: Options -> Maybe String
getGHCVerbosityFlag opts =
  verbosity opts <&> \i -> "-v" <> show i

log :: Options -> String -> IO ()
log opts msg =
  whenJust (verbosity opts) \_ ->
    putStrLn msg