seihou-core-0.14.0.0: src/Seihou/Composition/Recipe.hs
module Seihou.Composition.Recipe
( expandRecipe,
ExpandedRecipe (..),
ResolvedRecipeNode (..),
ResolvedRecipeMember (..),
RecipeLiteral (..),
mergeRecipeChain,
recipeLiteralSources,
)
where
import Control.Lens ((&), (.~), (^.))
import Control.Monad (foldM, unless, when)
import Data.Generics.Labels ()
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Maybe (isJust)
import Data.Text (Text)
import Data.Text qualified as T
import GHC.Generics (Generic)
import Seihou.Core.ArtifactIdentity (sameArtifactIdentity)
import Seihou.Core.Recipe (validateRecipe, validateRecipeVersion)
import Seihou.Core.Types
-- | Context belongs to the declaring recipe, even after member deduplication.
data ResolvedRecipeMember = ResolvedRecipeMember
{ dependency :: !Dependency,
declaringRecipe :: !RecipeName,
declaringOrigin :: !ArtifactOrigin,
declaringDirectory :: !FilePath,
memberOrigin :: !ArtifactOrigin
}
deriving stock (Eq, Show, Generic)
data ResolvedRecipeNode = ResolvedRecipeNode
{ definition :: !Recipe,
origin :: !ArtifactOrigin,
directory :: !FilePath,
members :: ![ResolvedRecipeMember]
}
deriving stock (Eq, Show, Generic)
data RecipeLiteral = RecipeLiteral
{ value :: !Text,
recipe :: !RecipeName,
origin :: !ArtifactOrigin,
member :: !ModuleName
}
deriving stock (Eq, Show, Generic)
data ExpandedRecipe = ExpandedRecipe
{ primary :: !ModuleName,
members :: ![ResolvedRecipeMember],
literals :: !(Map VarName RecipeLiteral),
declarations :: ![VarDecl],
prompts :: ![Prompt],
chain :: ![ResolvedRecipeNode]
}
deriving stock (Eq, Show, Generic)
-- | Compatibility API for flat recipes. Never silently discard an extension.
expandRecipe :: Recipe -> Either [Text] (ModuleName, [ModuleName], Map VarName Text, [VarDecl], [Prompt], [Dependency])
expandRecipe recipe = do
validated <- validateRecipe recipe
when (isJust (validated ^. #extends)) $
Left ["Recipe extends a base; use origin-aware recipe-chain expansion"]
case validated ^. #modules of
[] -> Left ["recipe must list at least one module"]
first : rest ->
Right
( first ^. #module_,
map (^. #module_) rest,
Map.unions (map (^. #vars) (validated ^. #modules)),
validated ^. #vars,
validated ^. #prompts,
validated ^. #modules
)
-- | Merge an already resolved oldest-first chain. IO lookup and source proof
-- happen before this function; no member's binding becomes a graph edge.
mergeRecipeChain :: [ResolvedRecipeNode] -> Either [Text] ExpandedRecipe
mergeRecipeChain [] = Left ["Recipe chain is empty"]
mergeRecipeChain nodes@(oldest : _) = do
mapM_ (validateRecipe . (^. #definition)) nodes
when (isJust (oldest ^. #definition . #extends)) (Left ["Unresolved oldest recipe base"])
checkLinks nodes
(mergedMembers, bindings, decls, questions) <- foldM layer ([], Map.empty, [], []) nodes
case mergedMembers of
[] -> Left ["Expanded recipe must list at least one module"]
first : _ -> do
-- Reuse descriptor checks on the complete declaration/prompt scope.
let lastRecipe = foldl (\_ n -> n ^. #definition) (oldest ^. #definition) nodes
final =
lastRecipe
& #extends .~ Nothing
& #moduleOverrides .~ []
& #promptOverrides .~ []
& #modules .~ map (^. #dependency) mergedMembers
& #vars .~ decls
& #prompts .~ questions
_ <- validateRecipe final
pure (ExpandedRecipe (first ^. #dependency . #module_) mergedMembers bindings decls questions nodes)
where
extended = length nodes > 1
checkLinks (parent : child : rest) = do
case child ^. #definition . #extends of
Just ref | ref ^. #recipe == parent ^. #definition . #name -> validateRecipeVersion ref (parent ^. #definition)
_ -> Left ["Recipe chain does not match its declared base"]
checkLinks (child : rest)
checkLinks [_] = Right ()
checkLinks [] = Right ()
layer (inherited, bindings, decls, questions) node = do
let descriptor = node ^. #definition
label = descriptor ^. #name . #unRecipeName
failHere :: Text -> Either [Text] a
failHere msg = Left ["Recipe '" <> label <> "': " <> msg]
unless (map (^. #dependency) (node ^. #members) == descriptor ^. #modules) $
failHere "resolved member context does not match descriptor"
unless (not extended || unique (map (^. #var) questions)) $
failHere "inherited prompts have ambiguous duplicate targets"
changed <- foldM (overrideMember failHere) inherited (descriptor ^. #moduleOverrides)
combined <- foldM (addMember failHere) changed (node ^. #members)
let entries =
[(dep ^. #module_, dep ^. #vars) | dep <- descriptor ^. #modules]
<> [(o ^. #module_, o ^. #vars) | o <- descriptor ^. #moduleOverrides]
delta <- foldM (addLiterals failHere node) Map.empty entries
mergedDecls <- foldM (addDeclaration failHere) decls (descriptor ^. #vars)
replaced <- foldM (overridePrompt failHere) questions (descriptor ^. #promptOverrides)
mergedPrompts <-
if extended
then foldM (addPrompt failHere) replaced (descriptor ^. #prompts)
else Right (descriptor ^. #prompts)
pure (combined, Map.union delta bindings, mergedDecls, mergedPrompts)
overrideMember failHere inherited override =
case filter (\m -> m ^. #dependency . #module_ == override ^. #module_) inherited of
[_] ->
Right
[ if m ^. #dependency . #module_ == override ^. #module_
then m & #dependency . #vars .~ Map.union (override ^. #vars) (m ^. #dependency . #vars)
else m
| m <- inherited
]
_ -> failHere "moduleOverrides must name exactly one inherited member"
addMember failHere acc member = case filter (\m -> m ^. #dependency . #module_ == member ^. #dependency . #module_) acc of
[] -> Right (acc <> [member])
[old]
| not (sameArtifactIdentity (old ^. #memberOrigin) (member ^. #memberOrigin)) -> failHere ("member '" <> member ^. #dependency . #module_ . #unModuleName <> "' requires different origins: " <> originLabel (old ^. #memberOrigin) <> " versus " <> originLabel (member ^. #memberOrigin))
| old ^. #dependency . #vars /= member ^. #dependency . #vars -> failHere ("repeated member '" <> member ^. #dependency . #module_ . #unModuleName <> "' bindings conflict; use moduleOverrides")
| otherwise -> Right acc
_ -> failHere "ambiguous member identity"
addLiterals failHere node acc (member, values) = foldM add acc (Map.toList values)
where
add current (name, value) = case Map.lookup name current of
Just old | extended && old ^. #value /= value -> failHere ("unequal literal '" <> name ^. #unVarName <> "' in members '" <> old ^. #member . #unModuleName <> "' and '" <> member ^. #unModuleName <> "' (modules/moduleOverrides)")
Just _ -> Right current
Nothing -> Right (Map.insert name (RecipeLiteral value (node ^. #definition . #name) (node ^. #origin) member) current)
addDeclaration failHere acc child = case filter (\d -> d ^. #name == child ^. #name) acc of
[] -> Right (acc <> [child])
[parent]
| parent ^. #type_ /= child ^. #type_ || parent ^. #required /= child ^. #required || parent ^. #validation /= child ^. #validation ->
failHere ("inherited variable '" <> child ^. #name . #unVarName <> "' type, requiredness or validation conflicts")
| otherwise ->
Right
[ if d ^. #name == child ^. #name
then
child
& #default_ .~ nearest (child ^. #default_) (parent ^. #default_)
& #description .~ nearest (child ^. #description) (parent ^. #description)
else d
| d <- acc
]
_ -> failHere "ambiguous variable declaration"
overridePrompt failHere acc child = case filter (\p -> p ^. #var == child ^. #var) acc of
[_] -> Right [if p ^. #var == child ^. #var then child else p | p <- acc]
_ -> failHere "promptOverrides must name exactly one inherited prompt"
addPrompt failHere acc child = case filter (\p -> p ^. #var == child ^. #var) acc of
[] -> Right (acc <> [child])
[old] | old == child -> Right acc
_ -> failHere "repeated prompt differs; use promptOverrides"
nearest (Just x) _ = Just x
nearest Nothing old = old
unique [] = True
unique (x : xs) = x `notElem` xs && unique xs
-- | Preserve source attribution when resolving declared and undeclared literals.
recipeLiteralSources :: ExpandedRecipe -> Map VarName (Text, VarSource)
recipeLiteralSources expanded = Map.map (\literal -> (literal ^. #value, FromRecipeBindingAt (literal ^. #recipe) (literal ^. #origin))) (expanded ^. #literals)
originLabel :: ArtifactOrigin -> Text
originLabel (RemoteOrigin url name _) = url <> "#" <> name
originLabel (ProjectOrigin path) = T.pack path
originLabel (LocalOrigin name) = name <> " (unverified local origin)"