cabal-add-0.1: src/Distribution/Client/Add.hs
{-# LANGUAGE ImportQualifiedPost #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE ViewPatterns #-}
-- |
-- Copyright: (c) 2023 Bodigrim
-- License: BSD-3-Clause
--
-- Building blocks of @cabal-add@ executable.
module Distribution.Client.Add (
parseCabalFile,
resolveComponent,
CommonStanza (..),
validateDependency,
Config (..),
executeConfig,
validateChanges,
) where
import Control.Applicative ((<|>))
import Control.Monad (guard)
import Control.Monad.Error.Class (MonadError, throwError)
import Data.ByteString (ByteString)
import Data.ByteString.Char8 qualified as B
import Data.ByteString.Internal (isSpaceChar8)
import Data.List qualified as L
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NE
import Data.Maybe (fromMaybe, isJust, mapMaybe)
import Data.Set (Set)
import Data.Set qualified as S
import Distribution.CabalSpecVersion (CabalSpecVersion (CabalSpecV1_0, CabalSpecV3_0))
import Distribution.Fields (
Field (..),
FieldLine (..),
Name (..),
SectionArg (..),
readFields,
)
import Distribution.PackageDescription (
ComponentName (..),
Dependency,
GenericPackageDescription (..),
LibraryName (..),
PackageDescription (..),
componentNameStanza,
componentNameString,
pkgName,
unPackageName,
unUnqualComponentName,
)
import Distribution.PackageDescription.Configuration (flattenPackageDescription)
import Distribution.PackageDescription.Parsec (
parseGenericPackageDescription,
parseGenericPackageDescriptionMaybe,
runParseResult,
)
import Distribution.Parsec (
Position (..),
eitherParsec,
showPError,
)
import Distribution.Simple.BuildTarget (
BuildTarget (BuildTargetComponent),
readUserBuildTargets,
resolveBuildTargets,
)
-- | Just a newtype wrapper, since @Cabal-syntax@ does not provide any.
newtype CommonStanza = CommonStanza {unCommonStanza :: ByteString}
deriving (Eq, Ord, Show)
-- | An input for 'executeConfig'.
data Config = Config
{ cnfOrigContents :: !ByteString
-- ^ Original Cabal file (with quirks patched,
-- see "Distribution.PackageDescription.Quirks"),
-- must be in sync with 'cnfFields'.
, cnfFields :: ![Field Position]
-- ^ Parsed (by 'readFields' or, more specifically, by 'parseCabalFile')
-- representation of the Cabal file,
-- must be in sync with 'cnfOrigContents'.
, cnfComponent :: !(Either CommonStanza ComponentName)
-- ^ Which component to update?
-- Usually constructed by 'resolveComponent'.
, cnfDependencies :: !(NonEmpty ByteString)
-- ^ Which dependencies to add?
-- Usually constructed by 'validateDependency'.
}
deriving (Eq, Show)
extractComponentNames :: GenericPackageDescription -> Set ComponentName
extractComponentNames GenericPackageDescription {..} =
foldMap (const $ S.singleton $ CLibName LMainLibName) condLibrary
<> foldMap (S.singleton . CLibName . LSubLibName . fst) condSubLibraries
<> foldMap (S.singleton . CFLibName . fst) condForeignLibs
<> foldMap (S.singleton . CExeName . fst) condExecutables
<> foldMap (S.singleton . CTestName . fst) condTestSuites
<> foldMap (S.singleton . CBenchName . fst) condBenchmarks
extractCommonStanzas :: [Field ann] -> Set CommonStanza
extractCommonStanzas = foldMap go
where
go = \case
Section (Name _ "common") [SecArgName _pos sectionArg] _subFields ->
S.singleton $ CommonStanza sectionArg
Section (Name _ "common") [SecArgStr _pos sectionArg] _subFields ->
S.singleton $ CommonStanza sectionArg
_ -> mempty
data Resolution a = NotFound | Resolved a | Ambiguous
deriving (Functor)
instance Semigroup (Resolution a) where
a@Resolved {} <> _ = a
_ <> a@Resolved {} = a
Ambiguous <> _ = Ambiguous
_ <> Ambiguous = Ambiguous
NotFound <> NotFound = NotFound
resolveMainLib :: Set ComponentName -> Resolution ComponentName
resolveMainLib knownNames
| CLibName LMainLibName `elem` knownNames = Resolved $ CLibName LMainLibName
| otherwise = NotFound
resolveDefaultComponent :: Set ComponentName -> (ComponentName -> Bool) -> Resolution ComponentName
resolveDefaultComponent knownNames predicate =
case filter predicate (S.toList knownNames) of
[] -> NotFound
[x] -> Resolved x
_ -> Ambiguous
isCLibName :: ComponentName -> Bool
isCLibName = \case
CLibName {} -> True
_ -> False
isCFLibName :: ComponentName -> Bool
isCFLibName = \case
CFLibName {} -> True
_ -> False
isCExeName :: ComponentName -> Bool
isCExeName = \case
CExeName {} -> True
_ -> False
isCTestName :: ComponentName -> Bool
isCTestName = \case
CTestName {} -> True
_ -> False
isCBenchName :: ComponentName -> Bool
isCBenchName = \case
CBenchName {} -> True
_ -> False
resolveToComponentName :: Set ComponentName -> Maybe String -> Resolution ComponentName
resolveToComponentName knownNames = \case
Nothing -> case S.minView knownNames of
Just (knownName, rest)
| S.null rest -> Resolved knownName
_ -> resolveMainLib knownNames
Just name
-- Cf. Distribution.Simple.BuildTarget.matchComponentKind
| name `elem` ["lib", "library"] ->
resolveMainLib knownNames
| name `elem` ["flib", "foreign-library"] ->
resolveDefaultComponent knownNames isCFLibName
| name `elem` ["exe", "executable"] ->
resolveDefaultComponent knownNames isCExeName
| name `elem` ["tst", "test", "test-suite"] ->
resolveDefaultComponent knownNames isCTestName
| name `elem` ["bench", "benchmark"] ->
resolveDefaultComponent knownNames isCBenchName
| otherwise ->
resolveDefaultComponent knownNames $ \x -> case componentNameString x of
Nothing -> False
Just xs -> unUnqualComponentName xs == name
specialComponents :: Set ComponentName -> Set String
specialComponents knownNames =
S.fromList $
mapMaybe isResolvable ["lib", "flib", "exe", "test", "bench"]
where
isResolvable xs = case resolveToComponentName knownNames (Just xs) of
Resolved {} -> Just xs
_ -> Nothing
resolveToCommonStanza :: Set CommonStanza -> Maybe String -> Resolution CommonStanza
resolveToCommonStanza knownNames (Just (CommonStanza . B.pack -> name))
| S.member name knownNames = Resolved name
resolveToCommonStanza _ _ = NotFound
isSection :: Field ann -> Bool
isSection = \case
Field {} -> False
Section {} -> True
-- | Parse Cabal file into two representations.
parseCabalFile
:: MonadError String m
=> FilePath
-- ^ File name, just for error reporting.
-> ByteString
-- ^ Contents of the Cabal file.
-> m ([Field Position], GenericPackageDescription)
-- ^ Parsed data, suitable for 'resolveComponent'.
parseCabalFile fileName contents = do
let legacyErr = "Legacy, unsectioned Cabal files are unsupported"
errorWithCtx msg =
throwError $
"Cannot parse input Cabal file "
++ fileName
++ " because:\n"
++ msg
fields <- case readFields contents of
Left err -> errorWithCtx $ show err
Right fs
| any isSection fs -> pure fs
| otherwise -> errorWithCtx legacyErr
packDescr <- case snd $ runParseResult $ parseGenericPackageDescription contents of
Left (_, err) ->
errorWithCtx $ L.unlines $ map (showPError fileName) $ NE.toList err
Right GenericPackageDescription {packageDescription = PackageDescription {specVersion = CabalSpecV1_0}} ->
errorWithCtx legacyErr
Right pd -> pure pd
pure (fields, packDescr)
readBuildTarget :: PackageDescription -> String -> Maybe ComponentName
readBuildTarget pkg targetStr =
readBuildTarget' pkg targetStr <|> readBuildTarget'' pkg targetStr
readBuildTarget' :: PackageDescription -> String -> Maybe ComponentName
readBuildTarget' pkg targetStr = do
let (_, utargets) = readUserBuildTargets [targetStr]
[utarget] <- pure utargets
let (_, btargets) = resolveBuildTargets pkg [(utarget, False)]
[BuildTargetComponent btarget] <- pure btargets
pure btarget
-- | Surprisingly, 'resolveBuildTargets' does not support package component.
-- Let's work around this limitation manually for now.
readBuildTarget'' :: PackageDescription -> String -> Maybe ComponentName
readBuildTarget'' pkg targetStr = do
(pref, ':' : suff) <- pure $ span (/= ':') targetStr
guard $ unPackageName (pkgName (package pkg)) == pref
readBuildTarget' pkg suff
-- | Resolve a raw component name.
resolveComponent
:: MonadError String m
=> FilePath
-- ^ File name, just for error reporting.
-> ([Field Position], GenericPackageDescription)
-- ^ Parsed Cabal file, as returned by 'parseCabalFile'.
-> Maybe String
-- ^ Component name (or default component if 'Nothing'),
-- roughly adhering to the syntax
-- of [component targets](https://cabal.readthedocs.io/en/3.12/cabal-commands.html#target-forms).
-> m (Either CommonStanza ComponentName)
-- ^ Resolved component.
resolveComponent _ (_, gpd) (Just component)
| Just cmp <- readBuildTarget (flattenPackageDescription gpd) component =
pure $ Right cmp
resolveComponent
fileName
(extractCommonStanzas -> commonStanzas, extractComponentNames -> componentNames)
component = case resolution of
NotFound -> throwError $ case component of
Nothing ->
"Default target component not found in "
++ fileName
++ ".\n"
++ knownTargetsHint
Just cmp ->
"Target component '"
++ cmp
++ "' not found in "
++ fileName
++ ".\n"
++ knownTargetsHint
Resolved cmp -> pure cmp
Ambiguous ->
throwError $
"Target component is ambiguous.\n"
++ knownTargetsHint
where
allTargets =
S.fromList (mapMaybe (fmap unUnqualComponentName . componentNameString) (S.toList componentNames))
<> S.map (B.unpack . unCommonStanza) commonStanzas
<> specialComponents componentNames
knownTargetsHint =
"Specify one with -c: "
++ L.intercalate ", " (S.toList allTargets)
++ "."
resolution =
fmap Right (resolveToComponentName componentNames component)
<> fmap Left (resolveToCommonStanza commonStanzas component)
-- | Validate [dependency syntax](https://cabal.readthedocs.io/en/3.12/cabal-package-description-file.html#pkg-field-build-depends),
-- checking whether Cabal would be able to parse it.
validateDependency
:: MonadError String m
=> CabalSpecVersion
-- ^ Cabal format version to adhere to.
-> String
-- ^ Raw dependency to add.
-> m ByteString
-- ^ Validated dependency as 'ByteString' (or an error).
validateDependency specVer d = case eitherParsec d of
Right (_ :: Dependency)
| specVer < CabalSpecV3_0 && elem ':' d ->
throwError $
"Cannot use the specified dependency '"
++ d
++ "' because cabal-version must be at least 3.0."
| otherwise -> pure $ B.pack d
Left err ->
throwError $
"Cannot parse the specified dependency '"
++ d
++ "' because:\n"
++ err
-- Both lines and rows are 1-based.
splitAtPosition :: Position -> ByteString -> (ByteString, ByteString)
splitAtPosition (Position line row) bs
| line <= 1 = B.splitAt (row - 1) bs
| otherwise = case L.drop (line - 2) nls of
[] -> (bs, mempty)
nl : _ -> B.splitAt (nl + row) bs
where
nls = B.elemIndices '\n' bs
splitAtPositionLine :: Position -> ByteString -> (ByteString, ByteString)
splitAtPositionLine (Position line _row) = splitAtPosition (Position line 1)
isComponent :: Either CommonStanza ComponentName -> Field a -> Bool
isComponent (Right cmp) = \case
Section (Name _ "library") [] _subFields
| cmp == CLibName LMainLibName ->
True
Section (Name _ sectionName) [SecArgName _pos sectionArg] _subFields
| sectionName <> " " <> sectionArg == B.pack (componentNameStanza cmp) ->
True
Section (Name _ sectionName) [SecArgStr _pos sectionArg] _subFields
| sectionName <> " " <> sectionArg == B.pack (componentNameStanza cmp) ->
True
_ -> False
isComponent (Left (CommonStanza commonName)) = \case
Section (Name _ "common") [SecArgName _pos sectionArg] _subFields ->
sectionArg == commonName
Section (Name _ "common") [SecArgStr _pos sectionArg] _subFields ->
sectionArg == commonName
_ -> False
findNonImportField :: [Field Position] -> Maybe Position
findNonImportField (Section _ _ subFields : rest) =
case filter (not . isImportField) subFields of
fld : _ -> Just $ getFieldNameAnn fld
[] -> case rest of
fld : _ -> case getFieldNameAnn fld of
Position line _ -> Just (Position line defaultRow)
[] -> Just (Position maxBound defaultRow)
where
defaultRow = case reverse subFields of
[] -> 3
fld : _ -> case getFieldNameAnn fld of
Position _ row -> row
findNonImportField _ = Nothing
isImportField :: Field a -> Bool
isImportField = \case
Field (Name _ fieldName) _ -> fieldName == "import"
Section {} -> False
getFieldNameAnn :: Field ann -> ann
getFieldNameAnn = \case
Field (Name ann _) _ -> ann
Section (Name ann _) _ _ -> ann
isBuildDependsField :: Field ann -> Bool
isBuildDependsField = \case
Field (Name _ "build-depends") _ -> True
_ -> False
detectLeadingComma :: ByteString -> Maybe ByteString
detectLeadingComma xs = case B.uncons xs of
Just (',', ys) -> Just $ B.cons ',' $ B.takeWhile (== ' ') ys
_ -> Nothing
dropRepeatingSpaces :: ByteString -> ByteString
dropRepeatingSpaces xs = case B.uncons xs of
Just (' ', ys) -> B.cons ' ' (B.dropWhile (== ' ') ys)
_ -> xs
-- | Find build-depends section and insert new
-- dependencies at the beginning, trying our best
-- to preserve formatting. This often breaks however
-- if there are comments in between build-depends.
fancyAlgorithm :: Config -> Maybe ByteString
fancyAlgorithm Config {cnfFields, cnfComponent, cnfOrigContents, cnfDependencies} = do
component <- L.find (isComponent cnfComponent) cnfFields
Section _ _ subFields <- pure component
buildDependsField <- L.find isBuildDependsField subFields
Field _ (FieldLine firstDepPos _dep : restDeps) <- pure buildDependsField
-- This is not really the second dependency:
-- it's a dependency on the next line.
let secondDepPos = case restDeps of
FieldLine pos _dep : _ -> Just pos
_ -> Nothing
fillerPred c = isSpaceChar8 c || c == ','
let (B.takeWhileEnd fillerPred -> pref, B.takeWhile fillerPred -> suff) =
splitAtPosition firstDepPos cnfOrigContents
prefSuff = pref <> suff
(afterLast, inBetween, beforeFirst) = case secondDepPos of
Nothing ->
( if B.any (== ',') prefSuff then pref' else "," <> pref'
, if B.any (== ',') prefSuff then prefSuff' else "," <> prefSuff'
, suff
)
where
prefSuff' = dropRepeatingSpaces prefSuff
pref' = dropRepeatingSpaces pref
Just pos ->
( if B.any (== ',') suff then pref1 else prefSuff1
, prefSuff1
, suff
)
where
prefSuff1 = pref1 <> suff1
(B.takeWhileEnd fillerPred -> pref1, B.takeWhile fillerPred -> suff1) =
splitAtPosition pos cnfOrigContents
let (beforeFirstDep, afterFirstDep) = splitAtPosition firstDepPos cnfOrigContents
newBuildDeps = beforeFirst <> B.intercalate inBetween (NE.toList cnfDependencies) <> afterLast
let ret = beforeFirstDep <> newBuildDeps <> afterFirstDep
pure ret
-- | Find build-depends section and insert new
-- dependencies at the beginning. Very limited effort
-- is put into preserving formatting.
niceAlgorithm :: Config -> Maybe ByteString
niceAlgorithm Config {cnfFields, cnfComponent, cnfOrigContents, cnfDependencies} = do
component <- L.find (isComponent cnfComponent) cnfFields
Section _ _ subFields <- pure component
buildDependsField <- L.find isBuildDependsField subFields
Field _ (FieldLine pos _dep : _) <- pure buildDependsField
let (before, after) = splitAtPosition pos cnfOrigContents
(_, buildDepsHeader) = splitAtPosition (getFieldNameAnn buildDependsField) before
filler = dropRepeatingSpaces $ B.drop 1 $ B.dropWhile (/= ':') buildDepsHeader
leadingCommaStyle = detectLeadingComma after
filler' = maybe ("," <> filler) (filler <>) leadingCommaStyle
newBuildDeps =
fromMaybe "" leadingCommaStyle
<> B.intercalate filler' (NE.toList cnfDependencies)
<> (if isJust leadingCommaStyle then filler else filler')
pure $
before <> newBuildDeps <> after
-- | Introduce a new build-depends section
-- after the last common stanza import.
-- This is not fancy, but very robust.
roughAlgorithm :: Config -> Maybe ByteString
roughAlgorithm Config {cnfFields, cnfComponent, cnfOrigContents, cnfDependencies} = do
let componentAndRest = L.dropWhile (not . isComponent cnfComponent) cnfFields
pos@(Position _ row) <- findNonImportField componentAndRest
let (before, after) = splitAtPositionLine pos cnfOrigContents
lineEnding' = B.takeWhileEnd isSpaceChar8 before
lineEnding = if B.null lineEnding' then "\n" else lineEnding'
needsNewlineBefore = maybe False ((/= '\n') . snd) (B.unsnoc before)
buildDeps =
(if needsNewlineBefore then lineEnding else "")
<> B.replicate (row - 1) ' '
<> "build-depends: "
<> B.intercalate ", " (NE.toList cnfDependencies)
<> lineEnding
pure $
before <> buildDeps <> after
-- | The main workhorse, adding dependencies to a specified component
-- in the Cabal file.
executeConfig
:: (Either CommonStanza ComponentName -> ByteString -> Bool)
-- ^ How to validate results? See 'validateChanges'.
-> Config
-- ^ Input arguments.
-> Maybe ByteString
-- ^ Updated contents, if validated successfully.
executeConfig validator cnf@Config {cnfComponent} =
L.find (validator cnfComponent) $
mapMaybe ($ cnf) [fancyAlgorithm, niceAlgorithm, roughAlgorithm]
-- | Validate that updates did not cause unexpected effects on other sections
-- of the Cabal file.
validateChanges
:: GenericPackageDescription
-- ^ Original package description.
-> Either CommonStanza ComponentName
-- ^ Which component was supposed to be updated?
-- Usually constructed by 'resolveComponent'.
-> ByteString
-- ^ Update Cabal file.
-> Bool
-- ^ Was the update successful?
validateChanges origPackDesc (Left _commonStanza) newContents =
case parseGenericPackageDescriptionMaybe newContents of
Nothing -> False
Just newPackDesc ->
packageDescription origPackDesc == packageDescription newPackDesc
&& gpdScannedVersion origPackDesc == gpdScannedVersion newPackDesc
&& genPackageFlags origPackDesc == genPackageFlags newPackDesc
validateChanges origPackDesc (Right component) newContents =
case parseGenericPackageDescriptionMaybe newContents of
Nothing -> False
Just newPackDesc ->
packageDescription origPackDesc == packageDescription newPackDesc
&& gpdScannedVersion origPackDesc == gpdScannedVersion newPackDesc
&& genPackageFlags origPackDesc == genPackageFlags newPackDesc
&& mainLibMatch
&& subLibsMatch
&& foreignLibsMatch
&& executablesMatch
&& testsMatch
&& benchmarksMatch
where
mainLibMatch = case (condLibrary origPackDesc, condLibrary newPackDesc) of
(Nothing, Nothing) -> True
(Just x, Just y) -> component == CLibName LMainLibName || x == y
_ -> False
subLibsMatch = length xs == length ys && and (zipWith predicate xs ys)
where
xs = condSubLibraries origPackDesc
ys = condSubLibraries newPackDesc
predicate x y = x == y || isCLibName component && fst x == fst y && componentNameString component == Just (fst x)
foreignLibsMatch = length xs == length ys && and (zipWith predicate xs ys)
where
xs = condForeignLibs origPackDesc
ys = condForeignLibs newPackDesc
predicate x y = x == y || isCFLibName component && fst x == fst y && componentNameString component == Just (fst x)
executablesMatch = length xs == length ys && and (zipWith predicate xs ys)
where
xs = condExecutables origPackDesc
ys = condExecutables newPackDesc
predicate x y = x == y || isCExeName component && fst x == fst y && componentNameString component == Just (fst x)
testsMatch = length xs == length ys && and (zipWith predicate xs ys)
where
xs = condTestSuites origPackDesc
ys = condTestSuites newPackDesc
predicate x y = x == y || isCTestName component && fst x == fst y && componentNameString component == Just (fst x)
benchmarksMatch = length xs == length ys && and (zipWith predicate xs ys)
where
xs = condBenchmarks origPackDesc
ys = condBenchmarks newPackDesc
predicate x y = x == y || isCBenchName component && fst x == fst y && componentNameString component == Just (fst x)