plexus-synapse-3.5.0: src/Synapse/CLI/Parse.hs
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# LANGUAGE LambdaCase #-}
-- | IR-driven parameter parsing for CLI
--
-- Transforms flat --key value pairs into properly nested JSON using IR type information.
--
-- = Problem
--
-- CLI flags are flat: @--identifier.type by_name --identifier.name foo@
-- Server expects nested: @{"identifier": {"type": "by_name", "name": "foo"}}@
--
-- = Solution
--
-- Use IR type definitions to understand the structure:
--
-- @
-- IR lookup: identifier -> RefNamed "ConeIdentifier"
-- IR lookup: ConeIdentifier -> KindEnum "type" [VariantDef "by_name" [...], ...]
-- Result: {"identifier": {"type": "by_name", "name": "foo"}}
-- @
--
-- = Design
--
-- Dotted keys are grouped by their first segment:
--
-- @
-- [("identifier.type", "by_name"), ("identifier.name", "foo"), ("prompt", "hi")]
-- -> Map.fromList [("identifier", [("type", "by_name"), ("name", "foo")]),
-- ("prompt", [("", "hi")])]
-- @
--
-- Each group is then built according to its parameter's TypeRef.
--
-- = Arrays
--
-- Repeated flags are collected into arrays:
--
-- @
-- --tags backend --tags critical --tags urgent
-- -> [("tags", [("", "backend"), ("", "critical"), ("", "urgent")])]
-- -> {"tags": ["backend", "critical", "urgent"]}
-- @
module Synapse.CLI.Parse
( -- * Parsing
parseParams
, ParseError(..)
-- * Helpers (exposed for testing)
, groupByPrefix
, buildParamValue
) where
import Data.Aeson (Value(..), object)
import qualified Data.Aeson.Key as K
import Data.List (find)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Text (Text)
import qualified Data.Text as T
import Data.Scientific (Scientific)
import qualified Data.Vector as V
import Text.Read (readMaybe)
import Synapse.IR.Types
import Synapse.CLI.Similarity (suggestCorrections)
-- ============================================================================
-- Error Types
-- ============================================================================
-- | Errors that can occur during parameter parsing
data ParseError
= UnknownParam Text [Text]
-- ^ Flag doesn't match any known parameter (param name, suggestions)
| MissingRequired Text
-- ^ Required parameter was not provided
| InvalidValue Text Text
-- ^ Invalid value for parameter (param name, reason)
| AmbiguousVariant Text [Text]
-- ^ Multiple variants could match (discriminator, variant names)
| MissingDiscriminator Text Text
-- ^ Enum type missing discriminator value (param name, discriminator field)
| UnknownVariant Text Text [Text] [Text]
-- ^ Unknown variant name (param name, provided value, valid variants, suggestions)
| TypeNotFound Text
-- ^ Referenced type not found in IR
deriving (Show, Eq)
-- ============================================================================
-- Main Parsing Function
-- ============================================================================
-- | Parse flat key-value pairs into nested JSON using IR structure
--
-- Example:
-- @
-- parseParams ir method [("identifier.type", "by_name"), ("identifier.name", "foo"), ("prompt", "hi")]
-- = Right (object [("identifier", object [("type", "by_name"), ("name", "foo")]),
-- ("prompt", "hi")])
-- @
parseParams :: IR -> MethodDef -> [(Text, Text)] -> Either [ParseError] Value
parseParams ir method kvPairs = do
-- Group keys by their top-level prefix
let grouped = groupByPrefix kvPairs
-- Build each parameter value
let paramDefs = mdParams method
let paramMap = Map.fromList [(pdName p, p) | p <- paramDefs]
-- Process each group and collect results/errors
let results = map (processGroup ir paramMap) (Map.toList grouped)
-- Check for missing required params
let providedParams = Map.keys grouped
let missingRequired =
[ MissingRequired (pdName p)
| p <- paramDefs
, pdRequired p
, pdName p `notElem` providedParams
, pdDefault p == Nothing -- Has no default value
]
-- Collect all errors and results
let (errors, values) = partitionResults results
let allErrors = errors ++ missingRequired
if null allErrors
then Right $ object values
else Left allErrors
-- | Process a single parameter group
processGroup :: IR -> Map Text ParamDef -> (Text, [(Text, Text)]) -> Either ParseError (K.Key, Value)
processGroup ir paramMap (paramName, subKeys) =
case Map.lookup paramName paramMap of
Nothing ->
let validParams = Map.keys paramMap
suggestions = suggestCorrections paramName validParams
in Left $ UnknownParam paramName suggestions
Just paramDef -> do
val <- buildParamValue ir paramDef subKeys
Right (K.fromText paramName, val)
-- | Partition results into errors and successes
partitionResults :: [Either ParseError (K.Key, Value)] -> ([ParseError], [(K.Key, Value)])
partitionResults = foldr partition ([], [])
where
partition (Left e) (errs, vals) = (e : errs, vals)
partition (Right v) (errs, vals) = (errs, v : vals)
-- ============================================================================
-- Key Grouping
-- ============================================================================
-- | Group dotted keys by their first segment
--
-- Keys without dots get empty suffix:
-- @
-- [("identifier.type", "by_name"), ("identifier.name", "foo"), ("prompt", "hi")]
-- -> Map.fromList [("identifier", [("type", "by_name"), ("name", "foo")]),
-- ("prompt", [("", "hi")])]
-- @
groupByPrefix :: [(Text, Text)] -> Map Text [(Text, Text)]
groupByPrefix = foldr addPair Map.empty
where
addPair (key, val) acc =
let (prefix, suffix) = splitKey key
in Map.insertWith (++) prefix [(suffix, val)] acc
splitKey key = case T.breakOn "." key of
(pre, rest)
| T.null rest -> (pre, "") -- No dot, empty suffix
| otherwise -> (pre, T.drop 1 rest) -- Drop the dot
-- ============================================================================
-- Value Building
-- ============================================================================
-- | Build value for a single param using its TypeRef
buildParamValue :: IR -> ParamDef -> [(Text, Text)] -> Either ParseError Value
buildParamValue ir param kvs = case pdType param of
RefPrimitive typ mFmt ->
-- Simple value, expect single kv with empty suffix
case lookup "" kvs of
Just val -> Right $ inferTypedValue typ mFmt val
Nothing -> Left $ InvalidValue (pdName param) "expected simple value"
RefNamed qn ->
let typeName = qualifiedNameFull qn
in case Map.lookup typeName (irTypes ir) of
Just typeDef -> buildFromTypeDef ir (pdName param) typeDef kvs
Nothing ->
-- Type not in IR, treat as simple value
case lookup "" kvs of
Just val -> Right $ String val
Nothing -> Left $ TypeNotFound typeName
RefOptional inner ->
-- For optional, if we have values, parse the inner type
if null kvs
then Right Null
else buildParamValue ir param{pdType = inner} kvs
RefArray innerType ->
-- Collect all repeated --key value entries, splitting comma-separated values
let rawValues = [val | ("", val) <- kvs]
values = concatMap splitCommas rawValues
in if null values
then Left $ InvalidValue (pdName param) "array requires at least one value"
else Right $ Array $ V.fromList $ map (buildTypedValue ir innerType) values
RefAny ->
-- Dynamic type, try to parse as JSON or use as string
case lookup "" kvs of
Just val -> Right $ inferValue val
Nothing -> Left $ InvalidValue (pdName param) "dynamic type needs value"
RefUnknown ->
-- Unknown type, best effort
case lookup "" kvs of
Just val -> Right $ inferValue val
Nothing -> Left $ InvalidValue (pdName param) "unknown type needs value"
-- | Split a string on commas, trimming whitespace. A value with no commas
-- returns as a singleton list, preserving backward compatibility.
splitCommas :: Text -> [Text]
splitCommas t =
let parts = T.splitOn "," t
in map T.strip parts
-- | Build value from a TypeDef
buildFromTypeDef :: IR -> Text -> TypeDef -> [(Text, Text)] -> Either ParseError Value
buildFromTypeDef ir paramName TypeDef{..} kvs = case tdKind of
KindEnum discriminator variants ->
-- Find which variant based on discriminator value
case lookup discriminator kvs of
Just variantName ->
case find (\v -> vdName v == variantName) variants of
Just variant -> buildVariant ir paramName discriminator variant kvs
Nothing ->
let validVariants = map vdName variants
suggestions = suggestCorrections variantName validVariants
in Left $ UnknownVariant paramName variantName validVariants suggestions
Nothing -> Left $ MissingDiscriminator paramName discriminator
KindStruct fields ->
-- Build object from fields
buildStruct ir paramName fields kvs
KindAlias target ->
-- Follow the alias
buildParamValue ir ParamDef{pdName = paramName, pdType = target, pdDescription = Nothing, pdRequired = True, pdDefault = Nothing} kvs
KindStringEnum allowedValues ->
-- Expect single string value from allowed set
case lookup "" kvs of
Just val
| val `elem` allowedValues -> Right $ String val
| otherwise -> Left $ InvalidValue paramName $
"must be one of: " <> T.intercalate ", " allowedValues
Nothing -> Left $ InvalidValue paramName "expected enum value"
KindPrimitive typ mFmt ->
-- Expect single value
case lookup "" kvs of
Just val -> Right $ inferTypedValue typ mFmt val
Nothing -> Left $ InvalidValue paramName "expected primitive value"
-- | Build a variant value (discriminator + variant fields)
buildVariant :: IR -> Text -> Text -> VariantDef -> [(Text, Text)] -> Either ParseError Value
buildVariant ir paramName discriminator VariantDef{..} kvs =
let discPair = (K.fromText discriminator, String vdName)
in case buildFieldPairs ir paramName vdFields kvs of
Right fieldPairs -> Right $ object (discPair : fieldPairs)
Left err -> Left err
-- | Build field pairs from a list of field definitions
buildFieldPairs :: IR -> Text -> [FieldDef] -> [(Text, Text)] -> Either ParseError [(K.Key, Value)]
buildFieldPairs ir paramName fields kvs = do
let results = map (buildFieldPair ir paramName kvs) fields
let (errors, pairs) = partitionResults results
if null errors
then Right pairs
else Left (head errors) -- Return first error
-- | Build a single field pair
buildFieldPair :: IR -> Text -> [(Text, Text)] -> FieldDef -> Either ParseError (K.Key, Value)
buildFieldPair ir paramName kvs FieldDef{..} =
case lookup fdName kvs of
Just val -> Right (K.fromText fdName, buildTypedValue ir fdType val)
Nothing
| fdRequired -> Left $ MissingRequired (paramName <> "." <> fdName)
| Just def <- fdDefault -> Right (K.fromText fdName, def)
| otherwise -> Right (K.fromText fdName, Null)
-- | Build a struct value from fields
buildStruct :: IR -> Text -> [FieldDef] -> [(Text, Text)] -> Either ParseError Value
buildStruct ir paramName fields kvs = do
pairs <- buildFieldPairs ir paramName fields kvs
Right $ object pairs
-- | Build a typed value from a TypeRef
buildTypedValue :: IR -> TypeRef -> Text -> Value
buildTypedValue ir ref val = case ref of
RefPrimitive typ mFmt -> inferTypedValue typ mFmt val
RefNamed qn ->
let typeName = qualifiedNameFull qn
in case Map.lookup typeName (irTypes ir) of
Just TypeDef{tdKind = KindPrimitive typ mFmt} -> inferTypedValue typ mFmt val
_ -> String val
RefOptional inner -> buildTypedValue ir inner val
RefArray _ -> inferValue val -- Best effort for arrays
RefAny -> inferValue val
RefUnknown -> String val
-- ============================================================================
-- Value Inference
-- ============================================================================
-- | Infer JSON value from string using type hints
inferTypedValue :: Text -> Maybe Text -> Text -> Value
inferTypedValue typ mFmt val = case typ of
"string" -> String val
"integer" -> case readMaybe (T.unpack val) :: Maybe Integer of
Just n -> Number (fromInteger n)
Nothing -> String val
"number" -> case readMaybe (T.unpack val) :: Maybe Double of
Just n -> Number (realToFrac n)
Nothing -> String val
"boolean" -> case T.toLower val of
"true" -> Bool True
"false" -> Bool False
"1" -> Bool True
"0" -> Bool False
_ -> String val
_ -> String val -- Default to string for unknown types
-- | Infer JSON value from string without type hints
inferValue :: Text -> Value
inferValue t
| t == "true" = Bool True
| t == "false" = Bool False
| t == "null" = Null
| Just n <- readMaybe (T.unpack t) :: Maybe Integer = Number (fromInteger n)
| Just n <- readMaybe (T.unpack t) :: Maybe Double = Number (realToFrac n)
| otherwise = String t
-- | Parse a string as JSON
parseJsonValue :: Text -> Either ParseError Value
parseJsonValue t =
-- Try to parse as JSON, fall back to string
Right $ inferValue t