packages feed

build-env-1.2.0.0: src/BuildEnv/CabalPlan.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DuplicateRecordFields #-}
{-# LANGUAGE RecordWildCards #-}

-- |
-- Module      :  BuildEnv.CabalPlan
-- Description :  Parsing Cabal plans from the @plan.json@ file format
--
-- This module parses the @plan.json@ Cabal plan files that are created
-- by @cabal-install@.
--
-- In the future, we hope to avoid doing this, and directly invoke Cabal's
-- solver to obtain the build plan, instead of invoking
-- @cabal-install build --dry-run@ and parsing the resulting @plan.json@ file.
module BuildEnv.CabalPlan
  (  -- * Build plans
    CabalPlan(..), mapMaybePlanUnits
  , CabalPlanBinary(..), parsePlanBinary

    -- * Packages
  , PkgName(..)
  , pkgNameVersion, validPackageName
  , mangledPkgName

    -- ** Allow-newer

  , AllowNewer(..)

    -- ** Package specification

  , PkgSpecs, PkgSpec(..)
  , emptyPkgSpec, parsePkgSpec
  , unionPkgSpecsOverriding

    -- *** Package constraints

  , Constraints(..)

    -- *** Package flags
  , FlagSpec(..)
  , showFlagSpec, flagSpecIsEmpty

    -- *** Package source location
  , PkgSrc(..)

   -- * Units

   -- ** Units

  , UnitId(..)
  , PlanUnit(..)
  , planUnitUnitId, planUnitPkgName, planUnitVersion

  , PreexistingUnit(..)
  , ConfiguredUnit(..)
  , configuredUnitMaybe, cuComponentType
  , allDepends, unitDepends


  -- *** Units within a package

  , UnitSpecs
  , unionUnitSpecsCombining

   -- ** Components
  , ComponentName(..)
  , cabalComponent, parsePkgComponent
  , ComponentType(..)
  , cabalComponentType, parseComponentType

  )
  where

-- base
import Data.Char
  ( isAlpha, isAlphaNum )
import Data.Maybe
  ( fromMaybe, mapMaybe )
import Data.Version
  ( Version, showVersion )

-- aeson
import Data.Aeson

-- bytestring
import qualified Data.ByteString.Lazy as Lazy
  ( ByteString )

-- containers
import qualified Data.Map.Strict as Strict
  ( Map )
import qualified Data.Map.Strict as Map
import Data.Set
  ( Set )

-- text
import Data.Text
  ( Text )
import qualified Data.Text as Text

-- build-env
import BuildEnv.Path

-------------------------------------------------------------------------------
-- Build plans

-- | Units in a Cabal @plan.json@ file.
newtype CabalPlan = CabalPlan { planUnits :: [PlanUnit] }

mapMaybePlanUnits :: (PlanUnit -> Maybe a) -> CabalPlan -> [a]
mapMaybePlanUnits f (CabalPlan units) = mapMaybe f units

instance Show CabalPlan where
  show (CabalPlan us)
    = unlines $ map show us

instance FromJSON CabalPlan where
    parseJSON = withObject "cabal plan" \ o ->
        CabalPlan <$> o .: "install-plan"

-- | Binary data underlying a @cabal@ @plan.json@ file.
newtype CabalPlanBinary = CabalPlanBinary Lazy.ByteString

-- | Decode a 'CabalPlanBinary' into a 'CabalPlan'.
parsePlanBinary :: CabalPlanBinary -> CabalPlan
parsePlanBinary (CabalPlanBinary pb) =
  case eitherDecode pb of
    Left  err  -> error ("parsePlanBinary: failed to parse plan JSON\n" ++ err)
    Right plan -> plan

-------------------------------------------------------------------------------
-- Packages

-- | A cabal package name, e.g. @lens@, @aeson@.
newtype PkgName = PkgName { unPkgName :: Text }
    deriving stock   Show
    deriving newtype (Eq, Ord, FromJSON, FromJSONKey)

-- | The @name-version@ string of a package.
pkgNameVersion :: PkgName -> Version -> Text
pkgNameVersion (PkgName n) v = n <> "-" <> Text.pack (showVersion v)

-- | Is the string a valid @cabal@ package name? That is, does it consist
-- only of alphanumeric identifiers and hyphens?
validPackageName :: Text -> Bool
validPackageName txt =
     Text.all ( \ x -> isAlphaNum x || x == '-' ) txt
  && case Text.uncons txt of
      Nothing    -> False
      Just (c,_) -> isAlpha c

-- | A Cabal mangled package name, in which @-@ has been replaced with @_@.
mangledPkgName :: PkgName -> String
mangledPkgName = map fixupChar . Text.unpack . unPkgName
  where
    fixupChar '-' = '_'
    fixupChar c   = c

-- | A collection of allow-newer specifications, e.g. @pkg1:pkg2,*:base@.
newtype AllowNewer = AllowNewer ( Set (Text, Text) )
  deriving stock Show
  deriving newtype ( Semigroup, Monoid )

-- | A mapping from a package name to its flags and constraints.
type PkgSpecs = Strict.Map PkgName PkgSpec

-- | Constraints and flags for a package.
data PkgSpec = PkgSpec { psConstraints :: !( Maybe Constraints )
                       , psFlags       :: !FlagSpec
                       }
  deriving stock Show

-- | No flags or constraints on a package.
emptyPkgSpec :: PkgSpec
emptyPkgSpec = PkgSpec Nothing mempty

-- | Parse flags and constraints (in that order).
parsePkgSpec :: Text -> PkgSpec
parsePkgSpec l = parseSpec Map.empty ( Text.words l )
  where
    parseSpec :: Strict.Map Text Bool -> [Text] -> PkgSpec
    parseSpec flags []
      = PkgSpec { psConstraints = Nothing
                , psFlags       = FlagSpec flags }
    parseSpec flags (w:ws)
      | Just (s,f) <- Text.uncons w
      , s == '+' || s == '-'
      = parseSpec (Map.insert f (s == '+') flags) ws
      | otherwise
      = PkgSpec { psConstraints = Just $ Constraints (Text.unwords (w:ws))
                , psFlags       = FlagSpec flags }

instance Semigroup PkgSpec where
  ( PkgSpec c1 f1 ) <> ( PkgSpec c2 f2 ) =
    PkgSpec ( c1 <> c2 )
            ( f1 <> f2 )

-- | Left-biased union of two sets of packages,
-- overriding flags and constraints of the second argument
-- with those provided in the first argument.
unionPkgSpecsOverriding :: PkgSpecs -> PkgSpecs -> PkgSpecs
unionPkgSpecsOverriding = Map.unionWith unionPkgSpec

-- | Combine two 'UnitSpecs'. Combines constraints and flags.
unionUnitSpecsCombining :: UnitSpecs -> UnitSpecs -> UnitSpecs
unionUnitSpecsCombining = Map.unionWith (<>)

-- | Left-biased union of package flags and constraints.
unionPkgSpec :: PkgSpec -> PkgSpec -> PkgSpec
unionPkgSpec (PkgSpec strongCts strongFlags) (PkgSpec weakCts weakFlags)
  = PkgSpec cts (strongFlags <> weakFlags)
    where
      cts = case strongCts of
        Nothing -> weakCts
        _       -> strongCts

-- | A collection of cabal constraints, e.g. @>= 3.2 && < 3.4@,
-- in raw textual format.
newtype Constraints = Constraints Text
  deriving stock Show

-- | Combine two constraints using @&&@.
instance Semigroup Constraints where
  Constraints c1 <> Constraints c2 =
    Constraints ( " ( " <> c1 <> " ) && ( " <> c2 <> " )" )

-- | Specification of package flags, e.g. @+foo -bar@.
--
-- @+@ corresponds to @True@ and @-@ to @False@.
newtype FlagSpec = FlagSpec (Strict.Map Text Bool)
    deriving stock   Show
    deriving newtype (Eq, Ord, Semigroup, Monoid, FromJSON)

showFlagSpec :: FlagSpec -> Text
showFlagSpec (FlagSpec fs) =
    Text.unwords
    [ sign <> flag
    | (flag, value) <- Map.toList fs
    , let sign = if value then "+" else "-"
    ]

flagSpecIsEmpty :: FlagSpec -> Bool
flagSpecIsEmpty (FlagSpec fs) = null fs

-- | The source location of a package.
--
--   - @Nothing@: it's in the package database (e.g. Hackage).
--   - @Just fp@: specified by the @cabal@ file at the given path.
data PkgSrc
  = Remote
  | Local !( SymbolicPath Project ( Dir Pkg ) )
  deriving stock Show

instance Semigroup PkgSrc where
  Remote <> b = b
  a <> _      = a

instance Monoid PkgSrc where
  mempty = Remote

instance FromJSON PkgSrc where
    parseJSON = withObject "package source" \ o -> do
      ty <- o .: "type"
      case ty :: Text of
        "local"       -> Local . mkSymbolicPath <$> o .: "path"
        "repo-tar"    -> return Remote
                         -- <$> o .: "repo"
        _ ->
          error $
               "parseJSON PkgSrc: unsupported 'pkg-src' field: "
            <> Text.unpack ty

-------------------------------------------------------------------------------
-- Units

-- | A unique identifier for a unit,
-- e.g. @lens-5.2-1bfd85cb66d2330e59a2f957e87cac993d922401@.
newtype UnitId = UnitId { unUnitId :: Text }
    deriving stock Show
    deriving newtype (Eq, Ord, FromJSON, FromJSONKey)

data PlanUnit
  = PU_Preexisting !PreexistingUnit
  | PU_Configured  !ConfiguredUnit
  deriving stock Show

planUnitUnitId :: PlanUnit -> UnitId
planUnitUnitId (PU_Preexisting (PreexistingUnit { puId })) = puId
planUnitUnitId (PU_Configured  (ConfiguredUnit  { puId })) = puId

planUnitPkgName :: PlanUnit -> PkgName
planUnitPkgName (PU_Preexisting (PreexistingUnit { puPkgName })) = puPkgName
planUnitPkgName (PU_Configured  (ConfiguredUnit  { puPkgName })) = puPkgName

planUnitVersion :: PlanUnit -> Version
planUnitVersion (PU_Preexisting (PreexistingUnit { puVersion })) = puVersion
planUnitVersion (PU_Configured  (ConfiguredUnit  { puVersion })) = puVersion

instance FromJSON PlanUnit where
    parseJSON = withObject "plan unit" \ o -> do
      ty <- o .: "type"
      case ty :: Text of
        "pre-existing" -> PU_Preexisting <$> preExisting o
        "configured"   -> PU_Configured  <$> configured  o
        _              -> error $
          "parseJSON PlanUnit: unexpected type " ++ Text.unpack ty ++ ",\n\
          \expecting 'pre-existing' or 'configured'"
      where
        preExisting o = do
          puId      <- o .: "id"
          puPkgName <- o .: "pkg-name"
          puVersion <- o .: "pkg-version"
          puDepends <- o .: "depends"
          return $ PreexistingUnit {..}

        configured o = do
           puId      <- o .:  "id"
           puPkgName <- o .:  "pkg-name"
           puVersion <- o .:  "pkg-version"
           puFlags   <- fromMaybe (FlagSpec Map.empty) <$> o .:? "flags"
           mbComps   <- o .:? "components"
           puPkgSrc  <- o .:  "pkg-src"
           (puComponentName, puDepends, puExeDepends, puSetupDepends) <-
             case mbComps of
               Nothing -> do
                 deps     <- o .: "depends"
                 exeDeps  <- o .: "exe-depends"
                 compName <- o .: "component-name"
                 let
                   comp
                     | compName == "lib"
                     = ComponentName Lib (unPkgName puPkgName)
                     | (ty,nm) <- Text.break (== ':') compName
                     , Just compTy <- parseComponentType ty
                     , not $ Text.null nm
                     = ComponentName compTy (Text.drop 1 nm)
                     | otherwise
                     = error $ "parseJSON PlanUnit: unsupported component name "
                            <> Text.unpack compName
                 return (comp, deps, exeDeps, [])
               Just comps -> do
                 lib       <- comps .:  "lib"
                 deps      <- lib   .:  "depends"
                 exeDeps   <- lib   .:  "exe-depends"
                 mbSetup   <- comps .:? "setup"
                 setupDeps <-
                   case mbSetup of
                     Nothing    -> return []
                     Just setup -> setup .: "depends"
                 return (ComponentName Lib (unPkgName puPkgName), deps, exeDeps, setupDeps)
           return $ ConfiguredUnit {..}

-- | Information about a built-in pre-existing unit (such as @base@).
data PreexistingUnit
  = PreexistingUnit
    { puId      :: !UnitId
    , puPkgName :: !PkgName
    , puVersion :: !Version
    , puDepends :: ![UnitId]
    }
  deriving stock Show

-- | Information about a unit: name, version, dependencies, flags.
data ConfiguredUnit
  = ConfiguredUnit
    { puId            :: !UnitId
    , puPkgName       :: !PkgName
    , puVersion       :: !Version
    , puComponentName :: !ComponentName
    , puFlags         :: !FlagSpec
    , puDepends       :: ![UnitId]
    , puExeDepends    :: ![UnitId]
    , puSetupDepends  :: ![UnitId]
    , puPkgSrc        :: !PkgSrc
    }
  deriving stock Show

configuredUnitMaybe :: PlanUnit -> Maybe ConfiguredUnit
configuredUnitMaybe (PU_Configured pu)  = Just pu
configuredUnitMaybe (PU_Preexisting {}) = Nothing

-- | Get what kind of component this unit is: @lib@, @exe@, etc.
cuComponentType :: ConfiguredUnit -> ComponentType
cuComponentType = componentType . puComponentName

-- | All the dependencies of a unit: @depends@, @exe-depends@ and @setup-depends@.
allDepends :: ConfiguredUnit -> [UnitId]
allDepends (ConfiguredUnit { puDepends, puExeDepends, puSetupDepends }) =
  puDepends ++ puExeDepends ++ puSetupDepends

-- | The dependencies of a unit, excluding @setup-depends@.
unitDepends :: ConfiguredUnit -> [UnitId]
unitDepends (ConfiguredUnit { puDepends, puExeDepends }) =
  puDepends ++ puExeDepends

-- | A mapping from a package name to its flags, constraints,
-- and components we want to build from it.
type UnitSpecs = Strict.Map PkgName ( PkgSrc, PkgSpec, Set ComponentName )

-- | The name of a cabal component, e.g. @lib:comp@.
data ComponentName =
  ComponentName { componentType :: !ComponentType
                   -- ^ What's before the colon, e.g. @lib@, @exe@, @setup@...
                , componentName :: !Text
                   -- ^ The actual name of the component.
                }
    deriving stock (Eq, Ord, Show)

-- | Print a cabal component using colon syntax @ty:comp@.
cabalComponent :: ComponentName -> Text
cabalComponent (ComponentName ty nm) = cabalComponentType ty <> ":" <> nm

-- | Parse a cabal package component, using the syntax @pkg:ty:comp@,
-- e.g. @attoparsec:lib:attoparsec-internal@.
parsePkgComponent :: Text -> Maybe ( PkgName, ComponentName )
parsePkgComponent txt = case Text.splitOn ":" txt of
  ty:pkg:[]
    | Just t <- parseComponentType ty
    , validPackageName pkg
    -> Just ( PkgName pkg, ComponentName t pkg )
  pkg:ty:comp:[]
    | Nothing <- parseComponentType pkg
    , validPackageName pkg
    , Just t <- parseComponentType ty
    , validPackageName comp
    -> Just ( PkgName pkg, ComponentName t comp )
  pkg:comp:[]
    | Nothing <- parseComponentType pkg
    , validPackageName pkg
    , validPackageName comp
    -> Just ( PkgName comp, ComponentName Lib comp )
  pkg:[]
    | Nothing <- parseComponentType pkg
    , validPackageName pkg
    -> Just ( PkgName pkg, ComponentName Lib pkg )
  _ -> Nothing

-- | The type of a component, e.g. library, executable, test-suite...
data ComponentType
  = Lib
  | FLib
  | Exe
  | Test
  | Bench
  | Setup
  deriving stock (Eq, Ord, Show)

-- | Print the cabal component type as expected in cabal colon syntax
-- @pkg:ty:comp@.
cabalComponentType :: ComponentType -> Text
cabalComponentType Lib   = "lib"
cabalComponentType FLib  = "flib"
cabalComponentType Exe   = "exe"
cabalComponentType Test  = "test"
cabalComponentType Bench = "bench"
cabalComponentType Setup = "setup"

-- | Parse the type of a @cabal@ component, e.g library, executable, etc.
parseComponentType :: Text -> Maybe ComponentType
parseComponentType "lib"   = Just Lib
parseComponentType "flib"  = Just FLib
parseComponentType "exe"   = Just Exe
parseComponentType "test"  = Just Test
parseComponentType "bench" = Just Bench
parseComponentType "setup" = Just Setup
parseComponentType _       = Nothing