cabal-install-3.18.1.0: src/Distribution/Client/ProjectConfig/Import.hs
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE GADTs #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE TupleSections #-}
{-# LANGUAGE ViewPatterns #-}
{-# LANGUAGE NoImplicitPrelude #-}
-- | Project configuration imports.
module Distribution.Client.ProjectConfig.Import
( -- * Parsing skeleton
ProjectConfigSkeleton
, projectSkeletonImports
, fetchImport
-- * Messages
, docProjectConfigFiles
, cyclicalImportMsg
, untrimmedUriImportMsg
-- * Checks
, reportDuplicateImports
) where
import Control.Arrow (Kleisli (..), arr, second, (>>>))
import qualified Data.ByteString.Char8 as BS
import Data.Coerce (coerce)
import Data.Function ((&))
import Data.Functor ((<&>))
import Data.List ((\\))
import qualified Data.Map as Map
import Distribution.Client.Compat.Prelude hiding (empty, (<>))
import qualified Distribution.Client.Compat.Prelude as Prelude ((<>))
import Distribution.Client.HttpUtils
import Distribution.Client.ProjectConfig.Types
import Distribution.Compat.Lens (view)
import Distribution.PackageDescription (ConfVar (..))
import Distribution.Simple.Utils (debug, noticeDoc, ordNub)
import Distribution.Solver.Types.ProjectConfigPath
import Distribution.Types.CondTree (CondTree (..), traverseCondTreeA)
import Distribution.Utils.String (trim)
import Network.URI (URI (..), parseURI)
import System.Directory (createDirectoryIfMissing)
import System.FilePath (isAbsolute, isPathSeparator, makeValid, (</>))
import Text.PrettyPrint (Doc, empty, int, nest, semi, text, vcat, (<>))
-- | ProjectConfigSkeleton is a tree of conditional blocks and imports wrapping
-- a config. It can be finalized by providing the conditional resolution info
-- and then resolving and downloading the imports
type ProjectConfigSkeleton = CondTree ConfVar ([(Maybe URI, ProjectConfigPath)], ProjectConfig)
projectSkeletonImports :: ProjectConfigSkeleton -> [(Maybe URI, ProjectConfigPath)]
projectSkeletonImports = fst . view traverseCondTreeA
-- | Fetch a local file import or remote URL import and parse it.
fetchImport
:: (ProjectConfigToParse -> IO a)
-> FilePath
-> HttpTransport
-> Verbosity
-> FilePath
-> ProjectConfigPath
-> IO (Maybe URI, a)
fetchImport parser cacheDir httpTransport verbosity projectDir normLocPath =
fetchImportConfig normLocPath >>= runKleisli (second (arr ProjectConfigToParse >>> Kleisli parser))
where
fetchImportConfig :: ProjectConfigPath -> IO (Maybe URI, BS.ByteString)
fetchImportConfig (ProjectConfigPath (pci :| _)) = do
debug verbosity $ "fetching import: " ++ pci
let mbUri = parseURI (trim pci)
(mbUri,) <$> case mbUri of
Just uri -> do
let fp = cacheDir </> map (\x -> if isPathSeparator x then '_' else x) (makeValid $ show uri)
createDirectoryIfMissing True cacheDir
_ <- downloadURI httpTransport verbosity uri fp
BS.readFile fp
Nothing ->
BS.readFile $
if isAbsolute pci then pci else coerce projectDir </> pci
-- | Not just any file path. The project itself.
newtype ProjectFilePath = ProjectFilePath FilePath
deriving (Eq, Generic)
-- | Isomorphic with 'ProjectConfigPath' but with separate constructors for the
-- root, imported file and imported URI.
data ProjectNode a where
ProjectRoot :: FilePath -> ProjectNode ProjectFilePath
ProjectFileImport :: FilePath -> ProjectConfigPath -> ProjectNode FilePath
ProjectUriImport :: URI -> ProjectConfigPath -> ProjectNode URI
instance Eq (ProjectNode a) where
(==) a b
| ProjectRoot root <- a
, ProjectRoot root' <- b =
root == root'
| ProjectFileImport importOf importBy <- a
, ProjectFileImport importOf' importBy' <- b =
(==)
(consProjectConfigPath importOf importBy)
(consProjectConfigPath importOf' importBy')
| ProjectUriImport importOf importBy <- a
, ProjectUriImport importOf' importBy' <- b =
(==)
(consProjectConfigPath (show importOf) importBy)
(consProjectConfigPath (show importOf') importBy')
instance Pretty (ProjectNode a) where
pretty = \case
ProjectRoot root -> text root
ProjectFileImport importOf importBy -> pretty $ consProjectConfigPath importOf importBy
ProjectUriImport importOf importBy -> pretty $ consProjectConfigPath (show importOf) importBy
instance Show (ProjectNode a) where show = prettyShow
-- | Sorts the same as 'ProjectConfigPath' does.
instance Ord (ProjectNode a) where
compare =
(compare :: ProjectConfigPath -> ProjectConfigPath -> Ordering)
`on` ( \case
ProjectRoot root -> ProjectConfigPath $ root :| []
ProjectFileImport importOf importBy -> consProjectConfigPath importOf importBy
ProjectUriImport importOf importBy -> consProjectConfigPath (show importOf) importBy
)
-- | Renders the paths as a list without showing which path imports another,
-- like this;
--
-- >- cabal.project
-- >- project-cabal/constraints.config
-- >- project-cabal/ghc-latest.config
-- >- project-cabal/ghc-options.config
-- >- project-cabal/pkgs.config
-- >- project-cabal/pkgs/benchmarks.config
-- >- project-cabal/pkgs/buildinfo.config
-- >- project-cabal/pkgs/cabal.config
-- >- project-cabal/pkgs/install.config
-- >- project-cabal/pkgs/integration-tests.config
-- >- project-cabal/pkgs/tests.config
--
--
-- >>> :{
-- do
-- let ps =
-- [ ProjectConfigPath ("cabal.project" :| [])
-- , ProjectConfigPath ("project-cabal/constraints.config" :| ["cabal.project"])
-- , ProjectConfigPath ("project-cabal/ghc-latest.config" :| ["cabal.project"])
-- , ProjectConfigPath ("project-cabal/ghc-options.config" :| ["cabal.project"])
-- , ProjectConfigPath ("project-cabal/pkgs.config" :| ["cabal.project"])
-- , ProjectConfigPath ("project-cabal/pkgs/benchmarks.config" :| ["project-cabal/pkgs.config","cabal.project"])
-- , ProjectConfigPath ("project-cabal/pkgs/buildinfo.config" :| ["project-cabal/pkgs.config","cabal.project"])
-- , ProjectConfigPath ("project-cabal/pkgs/cabal.config" :| ["project-cabal/pkgs.config","cabal.project"])
-- , ProjectConfigPath ("project-cabal/pkgs/install.config" :| ["project-cabal/pkgs.config","cabal.project"])
-- , ProjectConfigPath ("project-cabal/pkgs/integration-tests.config" :| ["project-cabal/pkgs.config","cabal.project"])
-- , ProjectConfigPath ("project-cabal/pkgs/tests.config" :| ["project-cabal/pkgs.config","cabal.project"])
-- ]
-- return . render $ docProjectConfigFiles ps
-- :}
-- "- cabal.project\n- project-cabal/constraints.config\n- project-cabal/ghc-latest.config\n- project-cabal/ghc-options.config\n- project-cabal/pkgs.config\n- project-cabal/pkgs/benchmarks.config\n- project-cabal/pkgs/buildinfo.config\n- project-cabal/pkgs/cabal.config\n- project-cabal/pkgs/install.config\n- project-cabal/pkgs/integration-tests.config\n- project-cabal/pkgs/tests.config"
--
-- The listing puts projects first, URLs last and sorts the other paths
-- lexically, dropping any duplicates, like this:
--
-- >- cabal.project
-- >- 0.config
-- >- 2.config
-- >- cfg/1.config
-- >- cfg/3.config
-- >- with-ghc.config
-- >- https://www.stackage.org/lts-21.25/cabal.config
--
-- >>> let p = ProjectConfigPath $ "cabal.project" :| []
-- >>> let a = ProjectConfigPath $ "0.config" :| ["cabal.project"]
-- >>> let b = ProjectConfigPath $ "cfg/1.config" :| ["0.config", "cabal.project"]
-- >>> let c = ProjectConfigPath $ "with.config" :| ["0.config", "cabal.project"]
-- >>> let d = ProjectConfigPath $ "2.config" :| ["cfg/1.config", "0.config", "cabal.project"]
-- >>> let e = ProjectConfigPath $ "cfg/3.config" :| ["2.config", "cfg/1.config", "0.config", "cabal.project"]
-- >>> let f = ProjectConfigPath $ "https://www.stackage.org/lts-21.25/cabal.config" :| ["2.config", "cfg/1.config", "0.config", "cabal.project"]
-- >>> let g = ProjectConfigPath $ "https://www.stackage.org/lts-21.25/cabal.config" :| ["cfg/3.config", "2.config", "cfg/1.config", "0.config", "cabal.project"]
-- >>> let ps = [p, a, b, c, d, e, f, g]
-- >>> render $ docProjectConfigFiles ps
-- "- cabal.project\n- 0.config\n- 2.config\n- cfg/1.config\n- cfg/3.config\n- with.config\n- https://www.stackage.org/lts-21.25/cabal.config"
docProjectConfigFiles :: [ProjectConfigPath] -> Doc
docProjectConfigFiles (sortBy compareLexicographically -> ps) =
vcat
[ text "-" <+> text p
| p <- ordNub [p | ProjectConfigPath (p :| _) <- ps]
]
-- | A message for a cyclical import, a "cyclical import of".
cyclicalImportMsg :: ProjectConfigPath -> Doc
cyclicalImportMsg path@(ProjectConfigPath (duplicate :| _)) =
seenImportMsg
(text "cyclical import of" <+> text duplicate <> semi)
(ProjectFileImport duplicate path)
[]
-- | A message for a duplicate import, a "duplicate import of". If a check for
-- cyclical imports has already been made then this would report a duplicate
-- import by two different paths.
duplicateImportMsg :: Doc -> ProjectNode a -> [ProjectNode a] -> Doc
duplicateImportMsg intro = seenImportMsg intro
seenImportMsg :: Doc -> ProjectNode a -> [ProjectNode a] -> Doc
seenImportMsg intro projectNode seenImports =
vcat
[ intro
, maybe empty (nest 2 . docProjectConfigPath) path
, nest 2 $
vcat
[ docProjectConfigPath i
| Just i <- importBy <$> filter ((duplicate ==) . importOf) seenImports
]
]
where
duplicate = importOf projectNode
path = importBy projectNode
importOf :: ProjectNode a -> FilePath
importOf = \case
ProjectRoot dup -> dup
ProjectFileImport dup _ -> dup
ProjectUriImport dup _ -> show dup
importBy :: ProjectNode a -> Maybe ProjectConfigPath
importBy = \case
ProjectRoot _ -> Nothing
ProjectFileImport _ by -> Just by
ProjectUriImport _ by -> Just by
-- | A message for an import that has leading or trailing spaces.
untrimmedUriImportMsg :: Doc -> ProjectConfigPath -> Doc
untrimmedUriImportMsg intro path =
vcat
[ intro <+> text "import has leading or trailing whitespace" <> semi
, nest 2 (docProjectConfigPath path)
]
-- | Detect and report any duplicate imports, including those missed when parsing.
--
-- Parsing catches cyclical imports and some but not all duplicate imports. In
-- particular, it doesn't catch when the same project configuration is imported
-- via different import paths.
reportDuplicateImports :: Verbosity -> ProjectConfigSkeleton -> IO ()
reportDuplicateImports verbosity skeleton = do
let (dupeRoots, dupeFiles, dupeUris) = detectDupes $ projectSkeletonImports skeleton
unless (Map.null dupeRoots) (noticeDoc verbosity $ vcat (dupesMsg <$> Map.toList dupeRoots))
unless (Map.null dupeFiles) (noticeDoc verbosity $ vcat (dupesMsg <$> Map.toList dupeFiles))
unless (Map.null dupeUris) (noticeDoc verbosity $ vcat (dupesMsg <$> Map.toList dupeUris))
toDupes :: Ord k => [(k, [ProjectNode a])] -> Map k [Dupes a]
toDupes xs =
xs
& Map.fromListWith (Prelude.<>)
& Map.filter ((> 1) . length)
<&> \ys -> [Dupes v ys | v <- ys]
detectDupes :: [(Maybe URI, ProjectConfigPath)] -> (DupesMap ProjectFilePath, DupesMap FilePath, DupesMap URI)
detectDupes xs = (toDupes roots, toDupes files, toDupes uris)
where
(<$$>) = fmap . fmap
roots =
[ (h, [ProjectRoot h])
| (Nothing, (h, Nothing)) <- unconsProjectConfigPath <$$> xs
]
files =
[ (h, [ProjectFileImport h (consProjectConfigPath h t)])
| (Nothing, (h, Just t)) <- unconsProjectConfigPath <$$> xs
]
uris =
[ (f, [ProjectUriImport u (consProjectConfigPath f t)])
| (Just u, (f, Just t)) <- unconsProjectConfigPath <$$> xs
, show u == f
]
data Dupes a = Dupes
{ dupesImport :: ProjectNode a
-- ^ The import that we're checking for duplicates.
, dupesImports :: [ProjectNode a]
-- ^ All the imports of this file.
}
deriving (Eq)
instance Ord (Dupes a) where
compare x y =
(compare `on` length . dupesImports) x y
`thenCmp` (compare `on` sort . dupesImports) x y
`thenCmp` (compare `on` dupesImport) x y
where
thenCmp :: Ordering -> Ordering -> Ordering
thenCmp EQ o2 = o2
thenCmp o1 _ = o1
type DupesMap a = Map FilePath [Dupes a]
dupesMsg :: (FilePath, [Dupes a]) -> Doc
dupesMsg (duplicate, ds@(take 1 . sort -> dupes)) =
vcat $
((text "Warning:" <+> int (length ds) <+> text "imports of" <+> text duplicate) <> semi)
: ((\Dupes{..} -> duplicateImportMsg empty dupesImport (sort $ dupesImports \\ [dupesImport])) <$> dupes)
-- $setup
-- >>> import Text.PrettyPrint (render)