packages feed

haskell-ci-0.12: src/HaskellCI.hs

{-# LANGUAGE DataKinds           #-}
{-# LANGUAGE MultiWayIf          #-}
{-# LANGUAGE OverloadedStrings   #-}
{-# LANGUAGE ScopedTypeVariables #-}
{-# LANGUAGE TypeApplications    #-}

-- | New-style @.travis.yml@ script generator using cabal 1.24's nix-style
-- tech-preview facilities.
--
-- See also <https://github.com/haskell-CI/haskell-ci>
--
-- NB: This code deliberately avoids relying on non-standard packages and
--     is expected to compile/work with at least GHC 7.0 through GHC 8.0
module HaskellCI (
    main,
    -- * for tests
    parseOptions,
    Options (..), defaultOptions,
    Config (..), GitConfig (..),
    InputType (..),
    runDiagnosticsT,
    -- ** Variants
    bashFromConfigFile,
    travisFromConfigFile,
    githubFromConfigFile,
    ) where

import HaskellCI.Prelude

import Control.Exception     (try)
import Data.List             (nubBy, sort, sortBy, (\\))
import System.Directory      (createDirectoryIfMissing, doesFileExist, setCurrentDirectory)
import System.Environment    (getArgs)
import System.Exit           (ExitCode (..), exitFailure)
import System.FilePath.Posix (takeDirectory)
import System.IO             (hClose, hFlush, hPutStrLn, stderr)
import System.IO.Temp        (withSystemTempFile)
import System.Process        (readProcessWithExitCode)

import Distribution.PackageDescription (GenericPackageDescription, package, packageDescription, testedWith)
import Distribution.Simple.Utils       (fromUTF8BS, toUTF8BS)
import Distribution.Text
import Distribution.Version

import qualified Data.ByteString       as BS
import qualified Data.List.NonEmpty    as NE
import qualified Data.Map              as Map
import qualified Data.Set              as S
import qualified Data.Traversable      as T
import qualified Distribution.Compiler as Compiler
import qualified Distribution.Package  as Pkg
import qualified Options.Applicative   as O

import Cabal.Parse
import Cabal.Project
import HaskellCI.Bash
import HaskellCI.Cli
import HaskellCI.Compiler
import HaskellCI.Config
import HaskellCI.Config.Dump
import HaskellCI.Diagnostics
import HaskellCI.GitConfig
import HaskellCI.GitHub
import HaskellCI.Jobs
import HaskellCI.Package
import HaskellCI.TestedWith
import HaskellCI.Travis
import HaskellCI.VersionInfo
import HaskellCI.YamlSyntax

import qualified HaskellCI.Bash.Template as Bash

-------------------------------------------------------------------------------
-- Main
-------------------------------------------------------------------------------

main :: IO ()
main = do
    argv0 <- getArgs
    (cmd, opts) <- O.customExecParser (O.prefs O.subparserInline) cliParserInfo
    case cmd of
        CommandListGHC -> do
            putStrLn $ "Supported GHC versions:"
            for_ groupedVersions $ \(v, vs) -> do
                putStr $ prettyMajVersion v ++ ": "
                putStrLn $ intercalate ", " (map display $ toList vs)

        CommandDumpConfig -> do
            putStr $ unlines $ runDG configGrammar

        CommandRegenerate -> do
            regenerateBash opts
            regenerateGitHub opts
            regenerateTravis opts

        CommandBash   f -> doBash argv0 f opts
        CommandGitHub f -> doGitHub argv0 f opts
        CommandTravis f -> doTravis argv0 f opts

        CommandVersionInfo -> do
            putStrLn $ "haskell-ci " ++ haskellCIVerStr ++ " with dependencies"
            ifor_ dependencies $ \p v -> do
                putStrLn $ "  " ++ p ++ "-" ++ v
  where
    groupedVersions :: [(Version, NonEmpty Version)]
    groupedVersions = map ((\vs -> (head vs, vs)) . NE.sortBy (flip compare))
                    . groupBy ((==) `on` ghcMajVer)
                    $ sort knownGhcVersions

    prettyMajVersion :: Version -> String
    prettyMajVersion v = case ghcMajVer v of
        (x, y) -> show x ++ "." ++ show y

    ifor_ :: Map.Map k v -> (k -> v -> IO a) -> IO ()
    ifor_ xs f = Map.foldlWithKey' (\m k a -> m >> void (f k a)) (return ()) xs

-------------------------------------------------------------------------------
-- Travis
-------------------------------------------------------------------------------

defaultTravisPath :: FilePath
defaultTravisPath = ".travis.yml"

doTravis :: [String] -> FilePath -> Options -> IO ()
doTravis args path opts = do
    contents <- travisFromConfigFile args opts path
    case optOutput opts of
        Nothing              -> BS.writeFile defaultTravisPath contents
        Just OutputStdout    -> BS.putStr contents
        Just (OutputFile fp) -> BS.writeFile fp contents

travisFromConfigFile
    :: forall m. (MonadIO m, MonadDiagnostics m, MonadMask m)
    => [String]
    -> Options
    -> FilePath
    -> m ByteString
travisFromConfigFile args opts path = do
    gitconfig <- liftIO readGitConfig
    cabalFiles <- getCabalFiles (optInputType' opts path) path
    config' <- findConfigFile (optConfig opts)
    let config = optConfigMorphism opts config'
    pkgs <- T.mapM (configFromCabalFile config) cabalFiles
    (ghcs, prj) <- case checkVersions (cfgTestedWith config) pkgs of
        Right x     -> return x
        Left []     -> putStrLnErr "panic: checkVersions failed without errors"
        Left (e:es) -> putStrLnErrs (e :| es)

    let prj' | cfgGhcHead config = over (mapped . field @"pkgJobs") (S.insert GHCHead) prj
             | otherwise         = prj

    ls <- genTravisFromConfigs args config gitconfig prj' ghcs
    patchTravis config ls

genTravisFromConfigs
    :: (Monad m, MonadDiagnostics m)
    => [String]
    -> Config
    -> GitConfig
    -> Project URI Void Package
    -> Set CompilerVersion
    -> m ByteString
genTravisFromConfigs argv config _gitconfig prj vs = do
    let jobVersions = makeJobVersions config vs
    case makeTravis argv config prj jobVersions of
        Left err     -> putStrLnErr $ displayException err
        Right travis -> do
            describeJobs "Travis-CI config" (cfgTestedWith config) jobVersions (prjPackages prj)
            return $ toUTF8BS $
                prettyYaml id (reann (travisHeader (cfgInsertVersion config) argv ++) $ toYaml travis)
                ++ unlines
                [ ""
                , "# REGENDATA " ++ if cfgInsertVersion config then show (haskellCIVerStr, argv) else show argv
                , "# EOF"
                ]

regenerateTravis :: Options -> IO ()
regenerateTravis opts = do
    let fp = defaultTravisPath

    -- change the directory
    for_ (optCwd opts) setCurrentDirectory

    -- read, and then change to the directory
    withContents fp noTravisYml $ \contents -> case findRegendataArgv contents of
        Nothing     -> do
            hPutStrLn stderr $ "Error: expected REGENDATA line in " ++ fp
            exitFailure

        Just (mversion, argv) -> do
            -- warn if we regenerate using older haskell-ci
            for_ mversion $ \version -> for_ (simpleParsec haskellCIVerStr) $ \haskellCIVer ->
                when (haskellCIVer < version) $ do
                    hPutStrLn stderr $ "Regenerating using older haskell-ci-" ++ haskellCIVerStr
                    hPutStrLn stderr $ "File generated using haskell-ci-" ++ prettyShow version

            (f, opts') <- parseOptions argv
            doTravis argv f ( optionsWithOutputFile fp <> opts' <> opts)
  where
    noTravisYml :: IO ()
    noTravisYml = putStrLn "No .travis.yml, skipping travis regeneration"

-------------------------------------------------------------------------------
-- Bash
-------------------------------------------------------------------------------

defaultBashPath :: FilePath
defaultBashPath = "haskell-ci.sh"

doBash :: [String] -> FilePath -> Options -> IO ()
doBash args path opts = do
    contents <- bashFromConfigFile args opts path
    case optOutput opts of
        Nothing              -> BS.writeFile defaultBashPath contents
        Just OutputStdout    -> BS.putStr contents
        Just (OutputFile fp) -> BS.writeFile fp contents

bashFromConfigFile
    :: forall m. (MonadIO m, MonadDiagnostics m, MonadMask m)
    => [String]
    -> Options
    -> FilePath
    -> m ByteString
bashFromConfigFile args opts path = do
    gitconfig <- liftIO readGitConfig
    cabalFiles <- getCabalFiles (optInputType' opts path) path
    config' <- findConfigFile (optConfig opts)
    let config = optConfigMorphism opts config'
    pkgs <- T.mapM (configFromCabalFile config) cabalFiles
    (ghcs, prj) <- case checkVersions (cfgTestedWith config) pkgs of
        Right x     -> return x
        Left []     -> putStrLnErr "panic: checkVersions failed without errors"
        Left (e:es) -> putStrLnErrs (e :| es)

    let prj' | cfgGhcHead config = over (mapped . field @"pkgJobs") (S.insert GHCHead) prj
             | otherwise         = prj

    genBashFromConfigs args config gitconfig prj' ghcs

genBashFromConfigs
    :: (Monad m, MonadIO m, MonadDiagnostics m)
    => [String]
    -> Config
    -> GitConfig
    -> Project URI Void Package
    -> Set CompilerVersion
    -> m ByteString
genBashFromConfigs argv config _gitconfig prj vs = do
    let jobVersions = makeJobVersions config vs
    case makeBash argv config prj jobVersions of
        Left err    -> putStrLnErr $ displayException err
        Right bashZ -> do
            describeJobs "Bash script" (cfgTestedWith config) jobVersions (prjPackages prj)
            fmap toUTF8BS $ liftIO $ Bash.renderIO bashZ
                { Bash.zRegendata = if cfgInsertVersion config then show (haskellCIVerStr, argv) else show argv
                }

regenerateBash :: Options -> IO ()
regenerateBash opts = do
    let fp = defaultBashPath

    -- change the directory
    for_ (optCwd opts) setCurrentDirectory

    -- read, and then change to the directory
    withContents fp noBashScript $ \contents -> case findRegendataArgv contents of
        Nothing     -> do
            hPutStrLn stderr $ "Error: expected REGENDATA line in " ++ fp
            exitFailure

        Just (mversion, argv) -> do
            -- warn if we regenerate using older haskell-ci
            for_ mversion $ \version -> for_ (simpleParsec haskellCIVerStr) $ \haskellCIVer ->
                when (haskellCIVer < version) $ do
                    hPutStrLn stderr $ "Regenerating using older haskell-ci-" ++ haskellCIVerStr
                    hPutStrLn stderr $ "File generated using haskell-ci-" ++ prettyShow version

            (f, opts') <- parseOptions argv
            doBash argv f ( optionsWithOutputFile fp <> opts' <> opts)
  where
    noBashScript :: IO ()
    noBashScript = putStrLn "No haskell-ci.sh, skipping bash regeneration"

-------------------------------------------------------------------------------
-- GitHub actions
-------------------------------------------------------------------------------

defaultGitHubPath :: FilePath
defaultGitHubPath = ".github/workflows/haskell-ci.yml"

doGitHub :: [String] -> FilePath -> Options -> IO ()
doGitHub args path opts = do
    contents <- githubFromConfigFile args opts path
    case optOutput opts of
        Nothing              -> do
            createDir defaultGitHubPath
            BS.writeFile defaultGitHubPath contents
        Just OutputStdout    -> BS.putStr contents
        Just (OutputFile fp) -> do
            createDir fp
            BS.writeFile fp contents
  where
    createDir p = createDirectoryIfMissing True (takeDirectory p)

githubFromConfigFile
    :: forall m. (MonadIO m, MonadDiagnostics m, MonadMask m)
    => [String]
    -> Options
    -> FilePath
    -> m ByteString
githubFromConfigFile args opts path = do
    gitconfig <- liftIO readGitConfig
    cabalFiles <- getCabalFiles (optInputType' opts path) path
    config' <- findConfigFile (optConfig opts)
    let config = optConfigMorphism opts config'
    pkgs <- T.mapM (configFromCabalFile config) cabalFiles
    (ghcs, prj) <- case checkVersions (cfgTestedWith config) pkgs of
        Right x     -> return x
        Left []     -> putStrLnErr "panic: checkVersions failed without errors"
        Left (e:es) -> putStrLnErrs (e :| es)

    let prj' | cfgGhcHead config = over (mapped . field @"pkgJobs") (S.insert GHCHead) prj
             | otherwise         = prj

    ls <- genGitHubFromConfigs args config gitconfig prj' ghcs
    patchGitHub config ls

genGitHubFromConfigs
    :: (Monad m, MonadIO m, MonadDiagnostics m)
    => [String]
    -> Config
    -> GitConfig
    -> Project URI Void Package
    -> Set CompilerVersion
    -> m ByteString
genGitHubFromConfigs argv config gitconfig prj vs = do
    let jobVersions = makeJobVersions config vs
    case makeGitHub argv config gitconfig prj jobVersions of
        Left err     -> putStrLnErr $ displayException err
        Right github -> do
            describeJobs "GitHub config" (cfgTestedWith config) jobVersions (prjPackages prj)
            return $ toUTF8BS $ prettyYaml id $ reann (githubHeader (cfgInsertVersion config) argv ++) $ toYaml github

regenerateGitHub :: Options -> IO ()
regenerateGitHub opts = do
    -- change the directory
    for_ (optCwd opts) setCurrentDirectory

    -- read, and then change to the directory
    withContents fp noGitHubScript $ \contents -> case findRegendataArgv contents of
        Nothing     -> do
            hPutStrLn stderr $ "Error: expected REGENDATA line in " ++ fp
            exitFailure

        Just (mversion, argv) -> do
            -- warn if we regenerate using older haskell-ci
            for_ mversion $ \version -> for_ (simpleParsec haskellCIVerStr) $ \haskellCIVer ->
                when (haskellCIVer < version) $ do
                    hPutStrLn stderr $ "Regenerating using older haskell-ci-" ++ haskellCIVerStr
                    hPutStrLn stderr $ "File generated using haskell-ci-" ++ prettyShow version

            (f, opts') <- parseOptions argv
            doGitHub argv f ( optionsWithOutputFile fp <> opts' <> opts)
  where
    fp = defaultGitHubPath

    noGitHubScript :: IO ()
    noGitHubScript = putStrLn $ "No " ++ fp ++ ", skipping GitHub config regeneration"

-------------------------------------------------------------------------------
-- Config file
-------------------------------------------------------------------------------

findConfigFile :: MonadIO m => ConfigOpt -> m Config
findConfigFile ConfigOptNo    = return emptyConfig
findConfigFile (ConfigOpt fp) = readConfigFile fp
findConfigFile ConfigOptAuto  = do
    let defaultPath = "cabal.haskell-ci"
    exists <- liftIO (doesFileExist defaultPath)
    if exists
    then readConfigFile defaultPath
    else return emptyConfig

-------------------------------------------------------------------------------
-- Patches
-------------------------------------------------------------------------------

patchTravis
    :: (MonadIO m, MonadMask m)
    => Config -> ByteString -> m ByteString
patchTravis = patchYAML . cfgTravisPatches

patchGitHub
    :: (MonadIO m, MonadMask m)
    => Config -> ByteString -> m ByteString
patchGitHub = patchYAML . cfgGitHubPatches

-- | Adjust the generated YAML output with patch files, if specified.
-- We do this in a temporary file in case the user did not pass --output (as
-- it would be awkward to patch the generated output otherwise).
patchYAML
    :: (MonadIO m, MonadMask m)
    => [FilePath] -> ByteString -> m ByteString
patchYAML patches input
  | null patches = pure input
  | otherwise =
      withSystemTempFile "yml.tmp" $ \fp h -> liftIO $ do
        BS.hPutStr h input
        hFlush h
        for_ patches $ applyPatch fp
        hClose h
        BS.readFile fp
  where
    applyPatch :: FilePath -- ^ The temporary file path to patch
               -> FilePath -- ^ The path of the .patch file
               -> IO ()
    applyPatch temp patch = do
        exists <- doesFileExist patch
        unless exists $ putStrLnErr $ "Cannot find " ++ patch
        (ec, stdOut, stdErr) <- readProcessWithExitCode
            "patch" [ "--input", patch
            , "--silent"
            , temp
            ] ""
        case ec of
            ExitSuccess -> pure ()
            ExitFailure n -> putStrLnErr $ unlines
                [ "patch returned exit code " ++ show n
                , "Stdout: " ++ stdOut
                , "Stderr: " ++ stdErr
                ]

-------------------------------------------------------------------------------
-- Utilities
-------------------------------------------------------------------------------

withContents
    :: FilePath            -- ^ filepath
    -> IO r                -- ^ what to do when file don't exist
    -> (String -> IO r)    -- ^ continuation
    -> IO r
withContents path no kont = do
    e <- try (BS.readFile path) :: IO (Either IOError BS.ByteString)
    case e of
        Left _         -> no
        Right contents -> kont (fromUTF8BS contents)

-- | Find @REGENDATA@ in a string
findRegendataArgv :: String -> Maybe (Maybe Version, [String])
findRegendataArgv contents = do
    l <- findMaybe (afterInfix "REGENDATA") (lines contents)
    first simpleParsec <$> (readMaybe l :: Maybe (String, [String]))
        <|> (,) Nothing <$> (readMaybe l :: Maybe [String])

-- | Read project file and associated .cabal files.
getCabalFiles
    :: (MonadDiagnostics m, MonadIO m)
    => InputType
    -> FilePath
    -> m (Project URI Void (FilePath, GenericPackageDescription))
getCabalFiles InputTypeProject path = do
    contents <- liftIO $ BS.readFile path
    prj0 <- either (putStrLnErr . renderParseError) return $ parseProject path contents
    prj1 <- either (putStrLnErr . renderResolveError) return =<< liftIO (resolveProject path prj0)
    either (putStrLnErr . renderParseError) return =<< liftIO (readPackagesOfProject prj1)
getCabalFiles InputTypePackage path = do
    e <- liftIO $ readPackagesOfProject (emptyProject & field @"prjPackages" .~ [path])
    either (putStrLnErr . renderParseError) return e

-------------------------------------------------------------------------------
-- Config
-------------------------------------------------------------------------------

configFromCabalFile
    :: (MonadIO m, MonadDiagnostics m)
    => Config -> (FilePath, GenericPackageDescription) -> m Package
configFromCabalFile cfg (cabalFile, gpd) = do
    let compilers = testedWith $ packageDescription gpd
        pkgNameStr = display $ Pkg.pkgName $ package $ packageDescription gpd

    let unknownComps = nub [ c | (c,_) <- compilers, c /= Compiler.GHC, c /= Compiler.GHCJS ]

        ghcVerConstrs   = [ vc | (Compiler.GHC,vc) <- compilers ]
        ghcVerConstrs'  = simplifyVersionRange $ foldr unionVersionRanges noVersion ghcVerConstrs
        specificGhcVers = nub $ mapMaybe isSpecificVersion ghcVerConstrs

        ghcjsVerConstrs   = [ vc | (Compiler.GHCJS,vc) <- compilers ]
        ghcjsVerConstrs'  = simplifyVersionRange $ foldr unionVersionRanges noVersion ghcjsVerConstrs
        specificGhcjsVers = nub $ mapMaybe isSpecificVersion ghcjsVerConstrs

        twoDigitGhcVerConstrs = mapMaybe isTwoDigitGhcVersion ghcVerConstrs :: [Version]

    unless (null twoDigitGhcVerConstrs) $ do
        putStrLnWarn $ "'tested-with:' uses two digit GHC versions (which don't match any existing GHC version): " ++ intercalate ", " (map display twoDigitGhcVerConstrs)
        putStrLnInfo $ "Either use wild-card format, for example 'tested-with: GHC ==7.10.*' or a specific existing version 'tested-with: GHC ==7.10.3'"

    when (null compilers) $ do
        putStrLnErr (unlines $
                     [ "empty or missing top-level 'tested-with:' definition in " ++ cabalFile ++ " file; example definition:"
                     , ""
                     , "tested-with: " ++ intercalate ", " [ "GHC==" ++ display v | v <- lastStableGhcVers ]
                     ])

    unless (null unknownComps) $ do
        putStrLnWarn $ "ignoring unsupported compilers mentioned in tested-with: " ++ show unknownComps

    when (null ghcVerConstrs) $ do
        putStrLnErr "'tested-with:' doesn't mention any 'GHC' version"

    when (isNoVersion ghcVerConstrs') $ do
        putStrLnErr "'tested-with:' describes an empty version range for 'GHC'"

    when (isAnyVersion ghcVerConstrs') $ do
        putStrLnErr "'tested-with:' allows /any/ 'GHC' version"

    let unknownGhcVers = sort $ specificGhcVers \\ knownGhcVersions
    unless (null unknownGhcVers) $ do
        putStrLnErr ("'tested-with:' specifically refers to unknown 'GHC' versions: "
                     ++ intercalate ", " (map display unknownGhcVers) ++ "\n"
                     ++ "Known GHC versions: " ++ intercalate ", " (map display knownGhcVersions))

    let unknownGhcjsVers = sort $ specificGhcjsVers \\ knownGhcjsVersions
    unless (null unknownGhcjsVers) $ do
        putStrLnErr ("'tested-with:' specifically refers to unknown 'GHCJS' versions: "
                     ++ intercalate ", " (map display unknownGhcjsVers) ++ "\n"
                     ++ "Known GHCJS versions: " ++ intercalate ", " (map display knownGhcjsVersions))

    let knownGhcVersions'
            | cfgLastInSeries cfg = filterLastMajor knownGhcVersions
            | otherwise           = knownGhcVersions

    let testedGhcVersions   = filter (`withinRange` ghcVerConstrs') knownGhcVersions'
    let testedGhcjsVersions = filter (`withinRange` ghcjsVerConstrs') knownGhcjsVersions

    when (null testedGhcVersions) $ do
        putStrLnErr "no known GHC version is allowed by the 'tested-with' specification"

    let compilerRange :: Set CompilerVersion
        compilerRange = S.fromList $
            [ GHC v
            | v <- testedGhcVersions
            ] ++
            [ GHCJS v
            | v <- testedGhcjsVersions
            ]

    let pkg = Pkg pkgNameStr compilerRange (takeDirectory cabalFile) gpd

    return pkg
  where
    lastStableGhcVers
        = nubBy ((==) `on` ghcMajVer)
        $ sortBy (flip compare)
        $ filter (not . previewGHC defaultHeadHackage . GHC)
        $ knownGhcVersions

    isTwoDigitGhcVersion :: VersionRange -> Maybe Version
    isTwoDigitGhcVersion vr = isSpecificVersion vr >>= t
      where
        t v | [_,_] <- versionNumbers v = Just v
        t _                             = Nothing

    filterLastMajor = map maximum . groupBy ((==) `on` ghcMajVer)