packages feed

cabal-add-0.2.1: app/Main.hs

{-# LANGUAGE ApplicativeDo #-}
{-# LANGUAGE CPP #-}
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PartialTypeSignatures #-}
{-# LANGUAGE RecordWildCards #-}
{-# OPTIONS_GHC -Wno-partial-type-signatures #-}

-- |
-- Copyright:   (c) 2023 Bodigrim
-- License:     BSD-3-Clause
module Main (main) where

import Control.Exception (throwIO)
import Control.Monad (filterM)
import Control.Monad.IO.Class (liftIO)
import Data.Bifunctor (bimap)
import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as B
import Data.Either (partitionEithers)
import Data.List qualified as L
import Data.List.NonEmpty (NonEmpty (..))
import Data.Maybe (catMaybes, isJust, mapMaybe)
import Distribution.CabalSpecVersion (CabalSpecVersion)
import Distribution.Client.Add
import Distribution.Client.DistDirLayout (
  defaultDistDirLayout,
  distProjectRootDirectory,
 )
import Distribution.Client.HttpUtils (configureTransport)
import Distribution.Client.ProjectConfig (
  ProjectPackageLocation (..),
  findProjectPackages,
  findProjectRoot,
  readProjectConfig,
 )
import Distribution.Client.RebuildMonad (runRebuild)
import Distribution.PackageDescription (
  ComponentName,
  GenericPackageDescription,
  ignoreConditions,
  packageDescription,
  specVersion,
 )
import Distribution.PackageDescription.Quirks (patchQuirks)
import Distribution.Verbosity qualified as Verbosity
import Options.Applicative (
  Parser,
  execParser,
  fullDesc,
  help,
  helper,
  info,
  long,
  metavar,
  optional,
  progDesc,
  strArgument,
  strOption,
 )
import Options.Applicative.NonEmpty (some1)
import System.Directory (doesFileExist, listDirectory)
import System.Environment (getArgs, lookupEnv, withArgs)
import System.Exit (die)
import System.FilePath (splitFileName, (</>))

#if MIN_VERSION_Cabal(3,17,0)
import Distribution.Client.ProjectConfig (ProjectFileParser(..))
#endif

data RawConfig = RawConfig
  { rcnfMProjectFile :: !(Maybe FilePath)
  , rcnfArgs :: !(NonEmpty String)
  }
  deriving (Show)

parseRawConfig :: Parser RawConfig
parseRawConfig = do
  rcnfMProjectFile <-
    optional $
      strOption $
        long "project-file"
          <> metavar "FILE"
          <> help "Set the path of the cabal.project file. Detect cabal.project or *.cabal in the current folder, if omitted."
  rcnfArgs <-
    some1 $
      strArgument $
        metavar "ARGS"
          <> help "Optional package component (wildcards such as 'exe', 'test' or 'bench' are supported) to update, followed by a non-empty list of package(s) to add to 'build-depends' section. Version bounds can be provided as well, use quotes to escape comparisons from your shell. E. g., 'foo < 0.2'."
  pure RawConfig {..}

resolveCabalProjectInCurrentFolder :: IO (Maybe FilePath)
resolveCabalProjectInCurrentFolder = do
  let fn = "cabal.project"
  exists <- doesFileExist fn
  pure $ if exists then Just fn else Nothing

resolveCabalFileInCurrentFolder :: IO (Either String FilePath)
resolveCabalFileInCurrentFolder = do
  files <- listDirectory "."

  -- filter in two steps to reduce IO
  let cabalFiles' = filter (".cabal" `L.isSuffixOf`) files
  -- make sure we don't catch directories
  cabalFiles <- filterM doesFileExist cabalFiles'

  pure $ case cabalFiles of
    [] ->
      Left "Found no cabal files in current folder. Giving up."
    [fn] ->
      Right fn
    _ : _ : _ ->
      Left "Found multiple cabal files in current folder. Giving up."

extractCabalFilesFromProject :: FilePath -> IO [FilePath]
extractCabalFilesFromProject projectFn = do
  let (projectDir, projectFile) = splitFileName projectFn
  mProjectRoot <- findProjectRoot silent (Just projectDir) (Just projectFile)
  case mProjectRoot of
    Left exc -> throwIO exc
    Right projectRoot -> do
      let dirLayout = defaultDistDirLayout projectRoot Nothing Nothing
          rootDirectory = distProjectRootDirectory dirLayout
      runRebuild rootDirectory $ do
        httpTransport <- liftIO $ configureTransport silent [] Nothing
        skeleton <- readProjectConfigSilently httpTransport mempty mempty dirLayout
        let projectConfig = fromSkeleton skeleton
        packages <- findProjectPackages dirLayout projectConfig
        pure $ map (rootDirectory </>) $ mapMaybe extractCabalFileFromProjectPackageLocation packages

#if MIN_VERSION_Cabal(3,17,0)
silent :: Verbosity.Verbosity
silent = Verbosity.Verbosity { verbosityFlags = Verbosity.silent, verbosityHandles = Verbosity.defaultVerbosityHandles }
#else
silent :: Verbosity.Verbosity
silent = Verbosity.silent
#endif

#if MIN_VERSION_Cabal(3,17,0)
readProjectConfigSilently :: _
readProjectConfigSilently = readProjectConfig silent ParsecParser
#else
readProjectConfigSilently :: _
readProjectConfigSilently = readProjectConfig silent
#endif

#if MIN_VERSION_Cabal(3,17,0)
fromSkeleton :: _
fromSkeleton = snd . ignoreConditions
#else
fromSkeleton :: _
fromSkeleton = fst . ignoreConditions
#endif

extractCabalFileFromProjectPackageLocation :: ProjectPackageLocation -> Maybe FilePath
extractCabalFileFromProjectPackageLocation = \case
  ProjectPackageLocalCabalFile fn -> Just fn
  ProjectPackageLocalDirectory _dn fn -> Just fn
  _ -> Nothing

resolveCabalFiles :: Maybe FilePath -> IO [FilePath]
resolveCabalFiles = \case
  Nothing -> do
    projectFn <- resolveCabalProjectInCurrentFolder
    case projectFn of
      Nothing -> do
        cabalFn <- resolveCabalFileInCurrentFolder
        case cabalFn of
          Left e -> die e
          Right fn -> pure [fn]
      Just fn -> extractCabalFilesFromProject fn
  Just fn -> extractCabalFilesFromProject fn

readCabalFile :: FilePath -> IO (Maybe ByteString)
readCabalFile fileName = do
  cabalFileExists <- doesFileExist fileName
  if cabalFileExists
    then Just . snd . patchQuirks <$> B.readFile fileName
    else pure Nothing

stripAdd :: [String] -> [String]
stripAdd ("add" : xs) = xs
stripAdd xs = xs

data Input = Input
  { inpFilePath :: FilePath
  , inpPackageDescription :: GenericPackageDescription
  , inpConfig :: AddConfig
  }
  deriving (Show)

mkInputs
  :: Bool
  -- ^ Must the first argument be a component name?
  -- As opposed to interpreting it as one of dependencies to add.
  -> FilePath
  -- ^ Cabal file name, primarily for error reporting.
  -- Note that 'mkInputs' does not do any 'IO'.
  -> ByteString
  -- ^ Cabal file content.
  -> NonEmpty String
  -- ^ List of arguments. The first one is either a component name
  -- or a dependency (see 'Bool' flag above), the rest are dependencies.
  -> Either String Input
  -- ^ Either an error or a tuple of input data.
mkInputs isCmpRequired cabalFile origContents args = do
  (fields, packDescr) <- parseCabalFile cabalFile origContents
  let specVer :: CabalSpecVersion
      specVer = specVersion $ packageDescription packDescr
      mkCmp :: Maybe String -> Either String (Either CommonStanza ComponentName)
      mkCmp = resolveComponent cabalFile (fields, packDescr)
      mkDeps :: NonEmpty String -> Either String (NonEmpty ByteString)
      mkDeps = traverse (validateDependency specVer)
  (cmp, deps) <- case args of
    x :| (y : ys)
      | Right c <- mkCmp (Just x) ->
          (c,) <$> mkDeps (y :| ys)
      | isCmpRequired -> Left $ "Target component '" ++ x ++ "' is not found"
    _ ->
      if isCmpRequired
        then Left "Target component is required"
        else (,) <$> mkCmp Nothing <*> mkDeps args
  pure $
    Input
      cabalFile
      packDescr
      AddConfig
        { cnfOrigContents = origContents
        , cnfFields = fields
        , cnfComponent = cmp
        , cnfTargetField = BuildDepends
        , cnfAdditions = deps
        }

disambiguateInputs
  :: Maybe FilePath
  -> [(FilePath, Either String Input)]
  -> Either String Input
disambiguateInputs mProjectFile inputs = case inputs' of
  ([], []) -> Left $ case mProjectFile of
    Nothing -> "No Cabal files or projects are found in the current folder, please specify --project-file."
    Just projFn -> "No Cabal files are found in " ++ projFn
  (errs, []) ->
    Left $
      L.intercalate "\n" $
        map (\(fn, err) -> "Cannot add a dependency to " ++ fn ++ " because: " ++ err) errs
  (_, [inp]) -> pure $ snd inp
  (_, _inps) ->
    Left
      "Cannot add a dependency, because target component is ambiguous. Please specify it as package:type:component. See https://cabal.readthedocs.io/en/latest/cabal-commands.html#target-forms for reference"
  where
    inputs' = partitionEithers $ map (\(fn, e) -> bimap (fn,) (fn,) e) inputs

main :: IO ()
main = do
  isCabalEnvVarSet <- isJust <$> lookupEnv "CABAL"
  rawArgs <- getArgs
  RawConfig {..} <-
    withArgs ((if isCabalEnvVarSet then stripAdd else id) rawArgs) $
      execParser $
        info
          (helper <*> parseRawConfig)
          (fullDesc <> progDesc "Extend build-depends from the command line")

  cabalFiles <- resolveCabalFiles rcnfMProjectFile
  cabalFilesAndContent <-
    catMaybes
      <$> traverse (\fn -> fmap (fn,) <$> readCabalFile fn) cabalFiles
  let getInput isCmpRequired =
        disambiguateInputs rcnfMProjectFile $
          map
            (\(fn, cnt) -> (fn, mkInputs isCmpRequired fn cnt rcnfArgs))
            cabalFilesAndContent

  input <- case getInput False of
    Right i -> pure i
    Left errFalse -> case rcnfArgs of
      _ :| [] -> die errFalse
      _ -> case getInput True of
        Right i -> pure i
        Left errTrue ->
          die $
            "If the first argument is interpreted as a dependency:\n"
              ++ errFalse
              ++ "\n\nIf the first argument is interpreted as a target component:\n"
              ++ errTrue

  let Input cabalFile origPackDescr cnf = input

  case executeAddConfig (validateChanges origPackDescr) cnf of
    Nothing ->
      die $
        "Cannot extend build-depends in "
          ++ cabalFile
          ++ ", please report as a bug at https://github.com/Bodigrim/cabal-add/issues"
    Just r -> B.writeFile cabalFile r