seihou-core-0.4.0.0: src/Seihou/Core/Variable.hs
module Seihou.Core.Variable
( resolveVariables,
coerceValue,
coerceDefault,
validateVarValue,
formatExplain,
formatDeclarations,
envVarName,
diagnoseResolution,
)
where
import Data.Char (toUpper)
import Data.Map.Strict qualified as Map
import Data.Maybe (catMaybes)
import Data.Set qualified as Set
import Data.Text qualified as T
import Seihou.Core.Types
import Seihou.Prelude
-- | Derive the environment variable name for a given variable.
-- @VarName "project.name"@ becomes @"SEIHOU_VAR_PROJECT_NAME"@.
envVarName :: VarName -> Text
envVarName (VarName name) =
"SEIHOU_VAR_" <> T.map (\c -> if c == '.' then '_' else toUpper c) name
-- | Attempt to coerce a text value to the declared variable type.
coerceValue :: VarName -> VarType -> Text -> Either VarError VarValue
coerceValue name VTText t = Right (VText t)
coerceValue name VTBool t =
case T.toLower (T.strip t) of
"true" -> Right (VBool True)
"yes" -> Right (VBool True)
"1" -> Right (VBool True)
"false" -> Right (VBool False)
"no" -> Right (VBool False)
"0" -> Right (VBool False)
_ -> Left (CoercionFailed name VTBool t)
coerceValue name VTInt t =
case reads (T.unpack (T.strip t)) of
[(n, "")] -> Right (VInt n)
_ -> Left (CoercionFailed name VTInt t)
coerceValue name (VTList VTText) t =
Right (VList (map (VText . T.strip) (T.splitOn "," t)))
coerceValue name (VTList elemTy) t =
Left (CoercionFailed name (VTList elemTy) t)
coerceValue name (VTChoice options) t
| T.strip t `elem` options = Right (VText (T.strip t))
| otherwise = Left (CoercionFailed name (VTChoice options) t)
-- | Coerce a module *default* value to its declared type.
--
-- Defaults are decoded from Dhall as raw 'VText'; this routes them through the
-- same 'coerceValue' contract as every other resolution source so a defaulted
-- variable reaches evaluation with the correct runtime type (e.g. a @bool@
-- default of @"true"@ becomes 'VBool' 'True', not 'VText' @"true"@).
--
-- A value that is already typed (e.g. a default synthesized during
-- composition) passes through unchanged. An unconstrained choice
-- (@VTChoice []@ — the only form the Dhall decoder currently produces, since
-- options are not yet carried in the type string) keeps its text value, as
-- there are no options to validate membership against.
coerceDefault :: VarName -> VarType -> VarValue -> Either VarError VarValue
coerceDefault _ (VTChoice []) (VText raw) = Right (VText raw)
coerceDefault name ty (VText raw) = coerceValue name ty raw
coerceDefault _ _ v = Right v
-- | Validate a resolved value against its declaration's validation constraint.
validateVarValue :: VarDecl -> VarValue -> Either VarError ()
validateVarValue decl val =
case decl.validation of
Nothing -> Right ()
Just v -> checkValidation decl.name v val
checkValidation :: VarName -> Validation -> VarValue -> Either VarError ()
checkValidation name (ValPattern pat) (VText t) =
if simplePatternMatch pat t
then Right ()
else Left (ValidationFailed name ("value does not match pattern: " <> pat))
checkValidation name (ValPattern _) _ =
Left (ValidationFailed name "pattern validation requires a text value")
checkValidation name (ValRange lo hi) (VInt n)
| n >= lo && n <= hi = Right ()
| otherwise =
Left
( ValidationFailed
name
( "value "
<> T.pack (show n)
<> " is not in range ["
<> T.pack (show lo)
<> ", "
<> T.pack (show hi)
<> "]"
)
)
checkValidation name (ValRange _ _) _ =
Left (ValidationFailed name "range validation requires an integer value")
checkValidation name (ValMinLength n) (VText t)
| T.length t >= n = Right ()
| otherwise =
Left
( ValidationFailed
name
("value must be at least " <> T.pack (show n) <> " characters")
)
checkValidation name (ValMinLength _) _ =
Left (ValidationFailed name "min-length validation requires a text value")
checkValidation name (ValMaxLength n) (VText t)
| T.length t <= n = Right ()
| otherwise =
Left
( ValidationFailed
name
("value must be at most " <> T.pack (show n) <> " characters")
)
checkValidation name (ValMaxLength _) _ =
Left (ValidationFailed name "max-length validation requires a text value")
-- | Simple pattern matching for variable validation.
-- For M2, this checks whether the text matches the character class pattern
-- @[a-z][a-z0-9-]*@ style patterns. A full regex library can be added later.
simplePatternMatch :: Text -> Text -> Bool
simplePatternMatch pat t
| pat == "[a-z][a-z0-9-]*" =
case T.uncons t of
Just (c, rest) ->
isLowerAlpha c && T.all (\x -> isLowerAlpha x || isDigit x || x == '-') rest
Nothing -> False
| otherwise = True -- unknown patterns pass by default
where
isLowerAlpha c = c >= 'a' && c <= 'z'
isDigit c = c >= '0' && c <= '9'
-- | Resolve all variables for a module given CLI overrides, environment variables,
-- four config file layers, and parent-supplied variable bindings.
--
-- Precedence chain (highest to lowest):
-- 1. CLI overrides (@--var@ flags)
-- 2. Environment variables (@SEIHOU_VAR_@ prefix)
-- 3. Local project config (@.seihou\/config.dhall@)
-- 4. Namespace config (@~\/.config\/seihou\/namespaces\/\<ns\>\/config.dhall@)
-- 5. Context config (@~\/.config\/seihou\/contexts\/\<ctx\>\/config.dhall@)
-- 6. Global config (@~\/.config\/seihou\/config.dhall@)
-- 7. Parent-supplied vars (from parameterized dependencies)
-- 8. Module defaults
resolveVariables ::
[VarDecl] ->
Map VarName Text -> -- CLI overrides
Map Text Text -> -- Environment variables
Text -> -- Namespace name (used in provenance tagging)
Text -> -- Context name (used in provenance tagging)
Map VarName Text -> -- Local config
Map VarName Text -> -- Namespace config
Map VarName Text -> -- Context config
Map VarName Text -> -- Global config
Map VarName (Text, ModuleName) -> -- Parent-supplied vars
Either [VarError] (Map VarName ResolvedVar)
resolveVariables decls cliOverrides envVars namespace context localConfig nsConfig ctxConfig globalConfig parentVars =
case partitionResults (map resolveOne decls) of
([], resolved) -> Right (Map.fromList (catMaybes resolved))
(errs, _) -> Left errs
where
resolveOne :: VarDecl -> Either VarError (Maybe (VarName, ResolvedVar))
resolveOne decl =
let name = decl.name
ty = decl.type_
in case lookupCLI name ty of
Just result -> fmap Just (result >>= validateAndWrap decl)
Nothing -> case lookupEnv name ty of
Just result -> fmap Just (result >>= validateAndWrap decl)
Nothing -> case lookupConfig name ty localConfig FromLocalConfig of
Just result -> fmap Just (result >>= validateAndWrap decl)
Nothing -> case lookupConfig name ty nsConfig (FromNamespaceConfig namespace) of
Just result -> fmap Just (result >>= validateAndWrap decl)
Nothing -> case lookupConfig name ty ctxConfig (FromContextConfig context) of
Just result -> fmap Just (result >>= validateAndWrap decl)
Nothing -> case lookupConfig name ty globalConfig FromGlobalConfig of
Just result -> fmap Just (result >>= validateAndWrap decl)
Nothing -> case lookupParent name ty of
Just result -> fmap Just (result >>= validateAndWrap decl)
Nothing -> case decl.default_ of
Just defVal ->
case coerceDefault name ty defVal of
Left err -> Left err
Right val -> fmap Just (validateAndWrap decl (val, FromDefault))
Nothing
| decl.required -> Left (MissingRequiredVar name)
| otherwise -> Right Nothing
lookupParent :: VarName -> VarType -> Maybe (Either VarError (VarValue, VarSource))
lookupParent name ty =
case Map.lookup name parentVars of
Nothing -> Nothing
Just (rawText, parentName) ->
Just $ case coerceValue name ty rawText of
Left err -> Left err
Right val -> Right (val, FromParent parentName)
lookupCLI :: VarName -> VarType -> Maybe (Either VarError (VarValue, VarSource))
lookupCLI name ty =
case Map.lookup name cliOverrides of
Nothing -> Nothing
Just rawText ->
Just $ case coerceValue name ty rawText of
Left err -> Left err
Right val -> Right (val, FromCLI)
lookupEnv :: VarName -> VarType -> Maybe (Either VarError (VarValue, VarSource))
lookupEnv name ty =
let envKey = envVarName name
in case Map.lookup envKey envVars of
Nothing -> Nothing
Just rawText ->
Just $ case coerceValue name ty rawText of
Left err -> Left err
Right val -> Right (val, FromEnv envKey)
lookupConfig :: VarName -> VarType -> Map VarName Text -> VarSource -> Maybe (Either VarError (VarValue, VarSource))
lookupConfig name ty configMap source =
case Map.lookup name configMap of
Nothing -> Nothing
Just rawText ->
Just $ case coerceValue name ty rawText of
Left err -> Left err
Right val -> Right (val, source)
validateAndWrap :: VarDecl -> (VarValue, VarSource) -> Either VarError (VarName, ResolvedVar)
validateAndWrap decl (val, source) =
case validateVarValue decl val of
Left err -> Left err
Right () ->
Right
( decl.name,
ResolvedVar
{ value = val,
source = source,
decl = decl
}
)
-- | Partition a list of Either into errors and successes.
partitionResults :: [Either e a] -> ([e], [a])
partitionResults = foldr go ([], [])
where
go (Left e) (errs, oks) = (e : errs, oks)
go (Right a) (errs, oks) = (errs, a : oks)
-- | Format a human-readable provenance report for @--explain@ output.
-- Uses bracket notation for sources and column-aligned output with 2-space indent.
formatExplain :: Map VarName ResolvedVar -> Text
formatExplain resolved =
T.unlines (map formatOne entries)
where
entries = Map.toAscList resolved
-- Calculate column widths for alignment
maxNameLen = maximum (0 : map (\(VarName n, _) -> T.length n) entries)
maxValueLen = maximum (0 : map (\(_, rv) -> T.length (showValue rv.value)) entries)
formatOne :: (VarName, ResolvedVar) -> Text
formatOne (VarName n, rv) =
let valText = showValue rv.value
namePad = T.replicate (maxNameLen - T.length n) " "
valPad = T.replicate (maxValueLen - T.length valText) " "
in " " <> n <> namePad <> " = " <> valText <> valPad <> " " <> showSource rv.source
showValue :: VarValue -> Text
showValue (VText t) = "\"" <> t <> "\""
showValue (VBool True) = "true"
showValue (VBool False) = "false"
showValue (VInt n) = T.pack (show n)
showValue (VList vs) = "[" <> T.intercalate ", " (map showValue vs) <> "]"
showSource :: VarSource -> Text
showSource FromCLI = "[--var]"
showSource (FromEnv envKey) = "[env " <> envKey <> "]"
showSource FromLocalConfig = "[local config]"
showSource (FromNamespaceConfig ns) = "[namespace: " <> ns <> "]"
showSource (FromContextConfig ctx) = "[context: " <> ctx <> "]"
showSource FromGlobalConfig = "[global config]"
showSource (FromParent mn) = "[parent: " <> mn.unModuleName <> "]"
showSource FromDefault = "[default]"
showSource FromPrompt = "[prompt]"
showSource (FromCommand cmd) = "[command: " <> cmd <> "]"
-- | Format variable declarations for default mode output.
-- Produces aligned output with @=@ signs and 2-space indent.
formatDeclarations :: [VarDecl] -> Text
formatDeclarations decls =
T.unlines (map formatOne decls)
where
maxNameLen = maximum (0 : map (\d -> T.length d.name.unVarName) decls)
formatOne :: VarDecl -> Text
formatOne d =
let VarName n = d.name
namePad = T.replicate (maxNameLen - T.length n) " "
valText = case d.default_ of
Nothing
| d.required -> "(required, no default)"
| otherwise -> "(optional, no default)"
Just v -> showDeclValue v
in " " <> n <> namePad <> " = " <> valText
showDeclValue :: VarValue -> Text
showDeclValue (VText t) = "\"" <> t <> "\""
showDeclValue (VBool True) = "true"
showDeclValue (VBool False) = "false"
showDeclValue (VInt n) = T.pack (show n)
showDeclValue (VList vs) = "[" <> T.intercalate ", " (map showDeclValue vs) <> "]"
-- | Diagnose mismatches between config values and variable declarations.
--
-- Returns two lists:
-- 1. Unused config keys: keys present in any config layer that don't match
-- any declared variable name across the composition.
-- 2. Unresolved optional variables: declared non-required variables that
-- have no resolved value.
diagnoseResolution ::
Map VarName ResolvedVar ->
[VarDecl] ->
Map VarName Text ->
Map VarName Text ->
Map VarName Text ->
Map VarName Text ->
([VarName], [VarName])
diagnoseResolution resolved decls localConfig nsConfig ctxConfig globalConfig =
(unusedConfigKeys, unresolvedOptional)
where
declaredNames = Set.fromList (map (.name) decls)
allConfigKeys =
Set.fromList $
Map.keys localConfig ++ Map.keys nsConfig ++ Map.keys ctxConfig ++ Map.keys globalConfig
unusedConfigKeys =
Set.toAscList (allConfigKeys `Set.difference` declaredNames)
unresolvedOptional =
[ d.name
| d <- decls,
not d.required,
not (Map.member d.name resolved)
]