packages feed

keiro-dsl-0.22.0.0: src/Keiro/Dsl/ExplainBindings.hs

{-# OPTIONS_GHC -Werror=incomplete-patterns #-}

-- | Consumer-owned Haskell obligations implied by checked structural mapped
-- declarations. The same values drive create-once skeletons, scaffold-record
-- diffs, and the @check --explain-bindings@ report.
module Keiro.Dsl.ExplainBindings
  ( BindingResolutionError (..),
    BindingObligationKind (..),
    BindingObligation (..),
    BindingHole (..),
    bindingObligations,
    bindingObligationsForService,
    structuralEnumEqualityIdentitiesForService,
    bindingHoles,
    bindingHolesForService,
    renderBindingObligations,
  )
where

import Data.Aeson (FromJSON (..), ToJSON (..), object, withObject, (.:), (.:?), (.=))
import Data.Bifunctor (first)
import Data.List (find, groupBy, sortOn)
import Data.List.NonEmpty (NonEmpty)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import Keiro.Dsl.AggregateType
import Keiro.Dsl.Grammar
import Keiro.Dsl.HaskellName qualified as HaskellName
import Keiro.Dsl.IdDomain (idDomainContractForDeclaration, idDomainVersion)
import Keiro.Dsl.LanguageVersion (RuntimeCapability (StructuralEnumEqualityGuards), runtimeProfileHasCapability)
import Keiro.Dsl.NominalType
import Keiro.Dsl.SemanticContract (CheckedService, EffectiveLanguageContract (..), checkedLanguageContract, checkedSpec, checkedTypeGraph, legacyCheckedService)
import Keiro.Dsl.TypeGraph

data BindingResolutionError
  = BindingTypeGraphError !TypeGraphError
  | BindingNominalTypeError !NominalTypeError
  deriving stock (Eq, Show)

data BindingObligationKind
  = BindingValue
  | FixtureValue
  | WriteLiftFunction
  | WriteLiftCases
  | InitialValue
  deriving stock (Eq, Ord, Show)

data BindingObligation = BindingObligation
  { mappedName :: !Name,
    package :: !Text,
    moduleName :: !Text,
    symbol :: !Text,
    kind :: !BindingObligationKind,
    signature :: !Text,
    useSites :: ![Text],
    bindingVersion :: !(Maybe Text),
    canonicalType :: !(Maybe Text),
    equalityContract :: !(Maybe Text),
    idDomainContract :: !(Maybe Text),
    category :: !Text
  }
  deriving stock (Eq, Ord, Show)

data BindingHole = BindingHole
  { mappedName :: !Name,
    moduleName :: !Text,
    symbol :: !Text,
    kind :: !BindingObligationKind,
    path :: !(Maybe Text),
    signature :: !Text
  }
  deriving stock (Eq, Ord, Show)

instance ToJSON BindingObligation where
  toJSON obligation =
    object
      [ "schema" .= (1 :: Int),
        "mappedName" .= (.mappedName) obligation,
        "package" .= (.package) obligation,
        "module" .= (.moduleName) obligation,
        "symbol" .= (.symbol) obligation,
        "kind" .= renderKind ((.kind) obligation),
        "signature" .= (.signature) obligation,
        "useSites" .= (.useSites) obligation,
        "bindingVersion" .= (.bindingVersion) obligation,
        "canonicalType" .= (.canonicalType) obligation,
        "equalityContract" .= (.equalityContract) obligation,
        "idDomainContract" .= (.idDomainContract) obligation,
        "category" .= (.category) obligation
      ]

instance FromJSON BindingObligation where
  parseJSON = withObject "keiro-dsl binding obligation" $ \value -> do
    schema <- value .: "schema"
    if schema /= (1 :: Int)
      then fail "unsupported binding obligation schema"
      else do
        kindText <- value .: "kind"
        kindValue <- maybe (fail "unknown binding obligation kind") pure (parseKind kindText)
        BindingObligation
          <$> value .: "mappedName"
          <*> value .: "package"
          <*> value .: "module"
          <*> value .: "symbol"
          <*> pure kindValue
          <*> value .: "signature"
          <*> value .: "useSites"
          <*> value .:? "bindingVersion"
          <*> value .:? "canonicalType"
          <*> value .:? "equalityContract"
          <*> value .:? "idDomainContract"
          <*> (value .:? "category" >>= pure . maybe "structural" id)

instance ToJSON BindingHole where
  toJSON hole =
    object
      [ "schema" .= (1 :: Int),
        "mappedName" .= (.mappedName) hole,
        "module" .= (.moduleName) hole,
        "symbol" .= (.symbol) hole,
        "kind" .= renderKind ((.kind) hole),
        "path" .= (.path) hole,
        "signature" .= (.signature) hole
      ]

instance FromJSON BindingHole where
  parseJSON = withObject "keiro-dsl binding hole" $ \value -> do
    schema <- value .: "schema"
    if schema /= (1 :: Int)
      then fail "unsupported binding hole schema"
      else do
        kindText <- value .: "kind"
        kindValue <- maybe (fail "unknown binding hole kind") pure (parseKind kindText)
        BindingHole
          <$> value .: "mappedName"
          <*> value .: "module"
          <*> value .: "symbol"
          <*> pure kindValue
          <*> value .:? "path"
          <*> value .: "signature"

-- | Declaration-scoped capability provenance. Executable binding laws remain
-- separate qualification evidence; checking cannot execute consumer functions.
structuralEnumEqualityIdentitiesForService :: CheckedService -> [Text]
structuralEnumEqualityIdentitiesForService service
  | runtimeProfileHasCapability (checkedLanguageContract service).runtimeProfile StructuralEnumEqualityGuards =
      case checkedTypeGraph service of
        Left _ -> []
        Right graph ->
          [ structuralEnumEqualityIdentity evidence
          | ResolvedStructural declaration REnum {} <- Map.elems graph.declarations,
            Right (AggregateStructuralEnum evidence) <- [resolveAggregateType (aggregateSymbolsFromGraph graph (checkedSpec service)) declaration.loc EqualityGuardUse (TRef declaration.name)]
          ]
  | otherwise = []

bindingObligations :: Spec -> Either (NonEmpty BindingResolutionError) [BindingObligation]
bindingObligations = bindingObligationsForService . legacyCheckedService

bindingObligationsForService :: CheckedService -> Either (NonEmpty BindingResolutionError) [BindingObligation]
bindingObligationsForService service = do
  graph <- first (fmap BindingTypeGraphError) (checkedTypeGraph service)
  nominalRegistry <- first (fmap BindingNominalTypeError) (resolveNominalTypes spec)
  pure . sortOn obligationSortKey $
    concat
      [ map (withEquality graph declaration) (obligationsFor graph declaration)
      | ResolvedStructural declaration _ <- Map.elems ((.declarations) graph)
      ]
      <> concatMap (liftObligationsFor graph) (Map.elems graph.declarations)
      <> concatMap (nominalObligationsFor service graph) (Map.elems (nominalTypes nominalRegistry))
  where
    spec = checkedSpec service
    withEquality graph declaration obligation =
      case resolveAggregateType (aggregateSymbolsFromGraph graph spec) declaration.loc EqualityGuardUse (TRef declaration.name) of
        Right (AggregateStructuralEnum evidence)
          | runtimeProfileHasCapability (checkedLanguageContract service).runtimeProfile StructuralEnumEqualityGuards -> obligation {equalityContract = Just (structuralEnumEqualityIdentity evidence)}
        _ -> obligation

bindingHoles :: Spec -> Either (NonEmpty BindingResolutionError) [BindingHole]
bindingHoles = bindingHolesForService . legacyCheckedService

bindingHolesForService :: CheckedService -> Either (NonEmpty BindingResolutionError) [BindingHole]
bindingHolesForService service = do
  graph <- first (fmap BindingTypeGraphError) (checkedTypeGraph service)
  obligations <- bindingObligationsForService service
  pure . sortOn holeSortKey $
    concat
      [ holesFor graph declaration shape obligations
      | ResolvedStructural declaration shape <- Map.elems ((.declarations) graph)
      ]
      <> [ BindingHole
             { mappedName = (.mappedName) obligation,
               moduleName = (.moduleName) obligation,
               symbol = (.symbol) obligation,
               kind = (.kind) obligation,
               path = Nothing,
               signature = (.signature) obligation
             }
         | obligation <- obligations,
           (.category) obligation /= "structural"
         ]

holesFor :: TypeGraph -> StructuralDecl -> ResolvedMappedShape -> [BindingObligation] -> [BindingHole]
holesFor _graph declaration shape obligations = bindingEntries <> auxiliaryEntries
  where
    own = filter ((== (.name) declaration) . (.mappedName)) obligations
    binding = onlyKind BindingValue
    bindingEntries = case binding of
      Nothing -> []
      Just obligation -> map (bindingHole obligation) (shapeHolePaths shape)
    auxiliaryEntries =
      [ BindingHole
          { mappedName = (.mappedName) obligation,
            moduleName = (.moduleName) obligation,
            symbol = (.symbol) obligation,
            kind = (.kind) obligation,
            path = Nothing,
            signature = (.signature) obligation
          }
      | obligation <- own,
        (.kind) obligation /= BindingValue
      ]
    onlyKind wanted = case filter ((== wanted) . (.kind)) own of
      entry : _ -> Just entry
      [] -> Nothing
    bindingHole obligation (path, expectedType) =
      BindingHole
        { mappedName = (.mappedName) obligation,
          moduleName = (.moduleName) obligation,
          symbol = (.symbol) obligation,
          kind = BindingValue,
          path = Just path,
          signature = (.symbol) obligation <> "." <> path <> " :: " <> expectedType
        }

shapeHolePaths :: ResolvedMappedShape -> [(Text, Text)]
shapeHolePaths =
  foldMappedShape
    MappedShapeAlgebra
      { onRecord = \_ _ fields -> [((.haskell) field, renderExprType ((.valueType) field)) | field <- fields],
        onEnum = \entries -> [((.ctor) entry, "constructor case") | entry <- entries],
        onUnion = \_ arms ->
          [ ((.ctor) arm, maybe "constructor case" renderExprType ((.payload) arm))
          | arm <- arms
          ],
        onBare = \expression -> [("value", renderExprType expression)],
        onRefined = const [("value", "ByteString")]
      }

renderExprType :: ResolvedTypeExpr -> Text
renderExprType =
  foldTypeExpr
    TypeExprAlgebra
      { onText = "Text",
        onInt = "Int",
        onInteger = "Integer",
        onBool = "Bool",
        onNatural = "Natural",
        onTime = "UTCTime",
        onDay = "Day",
        onTextSet = "Set Text",
        onJson = "Value",
        onOptional = \value -> "Maybe (" <> value <> ")",
        onList = \value -> "[" <> value <> "]",
        onMap = \value -> "Map Text (" <> value <> ")",
        onKeyedMap = \key value -> "Map " <> (.name) key <> " (" <> value <> ")",
        onRef = unMappedKey,
        onNominal = (.name)
      }

obligationsFor :: TypeGraph -> StructuralDecl -> [BindingObligation]
obligationsFor graph declaration = bindingEntry : fixtureEntry : initialEntries
  where
    source = (.haskell) declaration
    consumerType = (.moduleName) source <> "." <> (.valueType) source
    shapeType = (.name) declaration <> "Shape"
    paths = map renderUsePath (usePaths graph ((.name) declaration))
    registerPaths =
      [ renderUsePath path
      | path@UsePath {root = RootRegister {}} <- usePaths graph ((.name) declaration)
      ]
    bindingEntry =
      obligationFor
        declaration
        ((.binding) declaration)
        BindingValue
        ("StructuralBinding " <> consumerType <> " " <> shapeType)
        paths
        (Just (unBindingVersion ((.bindingVersion) declaration)))
        (Just (unCanonicalTypeId ((.canonical) declaration)))
    fixtureEntry =
      obligationFor
        declaration
        ((.fixtures) declaration)
        FixtureValue
        ("FixtureCases " <> consumerType)
        paths
        Nothing
        (Just (unCanonicalTypeId ((.canonical) declaration)))
    initialEntries = case (registerPaths, (.initial) declaration) of
      ([], _) -> []
      (_, Nothing) -> []
      (_, Just initialValue) ->
        [ obligationFor declaration initialValue InitialValue consumerType registerPaths Nothing (Just (unCanonicalTypeId ((.canonical) declaration)))
        ]

liftObligationsFor :: TypeGraph -> ResolvedMappedDecl -> [BindingObligation]
liftObligationsFor graph resolved = case details of
  (_, _, Nothing) -> []
  (name, source, Just lift) ->
    [ entry WriteLiftFunction lift.function (scalar <> " -> " <> target),
      entry WriteLiftCases lift.fixtures ("NonEmpty (Text, " <> scalar <> ", " <> target <> ")")
    ]
    where
      scalar = if lift.source == LiftTime then "UTCTime" else liftSourceName lift.source
      target = source.moduleName <> "." <> source.valueType
      entry kind qualified signature =
        BindingObligation
          { mappedName = name,
            package = source.package,
            moduleName = owner,
            symbol = symbol,
            kind = kind,
            signature = symbol <> " :: " <> signature,
            useSites = map renderUsePath (usePaths graph name),
            bindingVersion = Just (unLiftVersion lift.version),
            canonicalType = Nothing,
            equalityContract = Nothing,
            idDomainContract = Nothing,
            category = case resolved of ResolvedStructural {} -> "structural"; ResolvedOpaque {} -> "opaque"
          }
        where
          (owner, symbol) = splitQualified (unQualifiedValueName qualified)
  where
    details = case resolved of
      ResolvedStructural declaration _ -> (declaration.name, declaration.haskell, declaration.writeLift)
      ResolvedOpaque declaration -> (declaration.name, declaration.haskell, declaration.writeLift)

obligationFor :: StructuralDecl -> QualifiedValueName -> BindingObligationKind -> Text -> [Text] -> Maybe Text -> Maybe Text -> BindingObligation
obligationFor declaration qualified kindValue signature paths version canonical =
  BindingObligation
    { mappedName = (.name) declaration,
      package = (.package) ((.haskell) declaration),
      moduleName = ownerModule,
      symbol = symbol,
      kind = kindValue,
      signature = symbol <> " :: " <> signature,
      useSites = paths,
      bindingVersion = version,
      canonicalType = canonical,
      equalityContract = Nothing,
      idDomainContract = Nothing,
      category = "structural"
    }
  where
    (ownerModule, symbol) = splitQualified (unQualifiedValueName qualified)

nominalObligationsFor :: CheckedService -> TypeGraph -> ResolvedNominalType -> [BindingObligation]
nominalObligationsFor service graph nominal = case (.ownership) nominal of
  GeneratedNominal -> []
  ConsumerNominal binding -> bindingEntry : fixtureEntry : initialEntries
    where
      name = (.name) nominal
      source = (.haskell) binding
      consumerType = (.moduleName) source <> "." <> (.valueType) source
      paths =
        Set.toAscList . Set.fromList $
          useSites spec name <> map renderUsePath (nominalUsePaths graph name)
      registerPaths = [path | path <- paths, " register " `T.isInfixOf` path]
      category = case (.representation) nominal of
        IdRepresentation {} -> "nominal-id"
        EnumRepresentation {} -> "nominal-enum"
        ScalarRepresentation {} -> "nominal-scalar"
      representation = case (.representation) nominal of
        IdRepresentation prefix _ -> "(KindID " <> quoted prefix <> ")"
        EnumRepresentation {} -> nominalEnumRepresentationModule spec name <> "." <> name <> "Representation"
        ScalarRepresentation NominalText -> "Text"
        ScalarRepresentation NominalInt -> "Int"
        ScalarRepresentation NominalNatural -> "Natural"
        ScalarRepresentation NominalBool -> "Bool"
        ScalarRepresentation NominalTime -> "UTCTime"
      canonical = Just (unCanonicalTypeId ((.canonical) binding))
      equalityContract = nominalEqualityIdentityForService (checkedLanguageContract service) nominal
      idContract = case (.representation) nominal of
        IdRepresentation {} -> do
          declaration <- find ((== name) . (.name)) ((.ids) spec)
          idDomainVersion <$> idDomainContractForDeclaration (checkedLanguageContract service) declaration
        _ -> Nothing
      bindingEntry = nominalObligation name binding category ((.binding) binding) BindingValue ("NominalBinding " <> consumerType <> " " <> representation) paths (Just (unBindingVersion ((.bindingVersion) binding))) canonical equalityContract idContract
      fixtureEntry = nominalObligation name binding category ((.fixtures) binding) FixtureValue ("NominalFixtureCases " <> consumerType) paths Nothing canonical Nothing Nothing
      initialEntries = case (registerPaths, (.initial) binding) of
        ([], _) -> []
        (_, Nothing) -> []
        (_, Just initialValue) -> [nominalObligation name binding category initialValue InitialValue consumerType registerPaths Nothing canonical Nothing Nothing]
  where
    spec = checkedSpec service
    quoted value = T.pack (show value)

nominalObligation :: Name -> ConsumerNominalBinding -> Text -> QualifiedValueName -> BindingObligationKind -> Text -> [Text] -> Maybe Text -> Maybe Text -> Maybe Text -> Maybe Text -> BindingObligation
nominalObligation name binding category qualified kindValue signature paths version canonical equalityContract idDomainContract =
  BindingObligation
    { mappedName = name,
      package = (.package) ((.haskell) binding),
      moduleName = ownerModule,
      symbol = symbol,
      kind = kindValue,
      signature = symbol <> " :: " <> signature,
      useSites = paths,
      bindingVersion = version,
      canonicalType = canonical,
      equalityContract = equalityContract,
      idDomainContract = idDomainContract,
      category = category
    }
  where
    (ownerModule, symbol) = splitQualified (unQualifiedValueName qualified)

useSites :: Spec -> Name -> [Text]
useSites spec target = concatMap aggregatePaths [aggregate | NAggregate aggregate <- (.nodes) spec]
  where
    aggregatePaths aggregate =
      [ (.name) aggregate <> " command " <> (.name) command <> " ." <> (.name) field <> " : " <> target
      | command <- (.commands) aggregate,
        field <- (.fields) command,
        fieldUses field
      ]
        <> [ (.name) aggregate <> " event " <> (.name) event <> " ." <> (.name) field <> " : " <> target
           | event <- (.events) aggregate,
             field <- eventFields aggregate event,
             fieldUses field
           ]
        <> [ (.name) aggregate <> " register " <> (.name) register <> " : " <> target
           | register <- (.regs) aggregate,
             (.valueType) register == TRef target
           ]
    eventFields aggregate event = case (.body) event of
      EventFields fields -> fields
      EventFromCommand commandName -> concat [(.fields) command | command <- (.commands) aggregate, (.name) command == commandName]
    fieldUses field = (.valueType) field == Just (TRef target)

nominalEnumRepresentationModule :: Spec -> Name -> Text
nominalEnumRepresentationModule spec nominalName = case maybe GeneratedPrefix id ((.layout) spec) of
  GeneratedPrefix -> root <> "Generated." <> contextModuleName <> ".Nominal.Shape." <> nominalName
  CollocatedLeaf -> root <> contextModuleName <> ".Nominal.Shape." <> nominalName <> ".Generated"
  where
    root = maybe "" (<> ".") ((.moduleRoot) spec)
    contextModuleName =
      case HaskellName.deriveHaskellName HaskellName.LogicalWireWord site of
        Right derived -> HaskellName.renderUpperCamelName ((.upperCamel) derived)
        Left _ -> (.context) spec
    site =
      HaskellName.NameSite
        { HaskellName.kind = HaskellName.ContextModuleSite,
          HaskellName.logicalName = (.context) spec,
          HaskellName.owner = "binding-obligation-context",
          HaskellName.line = 0
        }

renderBindingObligations :: Text -> [BindingObligation] -> Text
renderBindingObligations context obligations = case obligations of
  [] -> "no binding obligations for context " <> context
  _ ->
    T.unlines $
      ["binding obligations for context " <> context]
        <> concatMap renderGroup grouped
  where
    grouped = groupBy sameOwner (sortOn obligationSortKey obligations)
    sameOwner left right = ownerKey left == ownerKey right
    renderGroup [] = []
    renderGroup entries@(firstEntry : _) =
      ("  " <> (.moduleName) firstEntry <> " (package " <> (.package) firstEntry <> ")")
        : concatMap renderEntry entries
    renderEntry obligation =
      [ "    " <> (.signature) obligation,
        "      reason: " <> renderKind ((.kind) obligation) <> " — " <> (.category) obligation <> " type " <> (.mappedName) obligation <> renderPaths ((.useSites) obligation)
      ]
        <> maybe [] (\version -> ["      provenance: binding-version " <> quoted version]) ((.bindingVersion) obligation)
        <> maybe [] (\canonical -> ["      canonical-type: " <> quoted canonical]) ((.canonicalType) obligation)
        <> maybe [] (\contract -> ["      equality-contract: " <> quoted contract]) ((.equalityContract) obligation)
        <> maybe [] (\contract -> ["      id-domain-contract: " <> quoted contract]) ((.idDomainContract) obligation)
        <> [ "      ordering-contract: Ord " <> (.mappedName) obligation <> " must agree with canonical TypeID text order"
           | (.category) obligation == "nominal-id",
             any (" {key}" `T.isInfixOf`) ((.useSites) obligation)
           ]
    renderPaths [] = " (not currently used by an aggregate root)"
    renderPaths paths = " (" <> T.intercalate "; " paths <> ")"
    quoted value = T.pack (show value)

obligationSortKey :: BindingObligation -> (Text, Text, Text, BindingObligationKind, Text)
obligationSortKey obligation =
  ( (.package) obligation,
    (.moduleName) obligation,
    (.mappedName) obligation,
    (.kind) obligation,
    (.symbol) obligation
  )

ownerKey :: BindingObligation -> (Text, Text)
ownerKey obligation = ((.package) obligation, (.moduleName) obligation)

holeSortKey :: BindingHole -> (Text, Name, BindingObligationKind, Maybe Text, Text)
holeSortKey hole =
  ((.moduleName) hole, (.mappedName) hole, (.kind) hole, (.path) hole, (.symbol) hole)

renderKind :: BindingObligationKind -> Text
renderKind BindingValue = "binding"
renderKind FixtureValue = "fixtures"
renderKind WriteLiftFunction = "write-lift-function"
renderKind WriteLiftCases = "write-lift-cases"
renderKind InitialValue = "initial-value"

parseKind :: Text -> Maybe BindingObligationKind
parseKind "write-lift-function" = Just WriteLiftFunction
parseKind "write-lift-cases" = Just WriteLiftCases
parseKind "binding" = Just BindingValue
parseKind "fixtures" = Just FixtureValue
parseKind "initial-value" = Just InitialValue
parseKind _ = Nothing

splitQualified :: Text -> (Text, Text)
splitQualified value =
  let (prefix, name) = T.breakOnEnd "." value
   in (T.dropEnd 1 prefix, name)