plexus-synapse-3.15.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"]}
-- @
--
-- For a list of scalars, each flag value is also a comma-separated record
-- (see 'splitListValue'):
--
-- @
-- --tags backend,critical --tags '"a,b"'
-- -> {"tags": ["backend", "critical", "a,b"]}
-- @
--
-- = Command line
--
-- The pairs come from the command line in two passes. 'parsePathAndParams'
-- runs before the method is known and finds the path; 'pairFlags' runs once
-- it is, and uses each parameter's type to decide whether a flag takes the
-- argument after it, so a value may begin with dashes:
--
-- @
-- --summary --body "--- id: X" --limit=3 (summary a boolean)
-- -> [("summary", "true"), ("body", "--- id: X"), ("limit", "3")]
-- @
module Synapse.CLI.Parse
( -- * Command line
parsePathAndParams
, pairFlags
-- * Parsing
, parseParams
, ParseError(..)
-- * List values
, isScalarElement
, splitListValue
-- * Helpers (exposed for testing)
, groupByPrefix
, buildParamValue
) where
import Control.Monad (foldM)
import Data.Aeson (Value(..), object)
import qualified Data.Aeson.Key as K
import Data.Either (partitionEithers)
import Data.List (find)
import Data.Maybe (fromMaybe)
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
| MalformedListValue Text Text Text
-- ^ List flag value with broken quoting (param name, flag value, reason)
| UnexpectedArgument Text
-- ^ Argument that no flag takes as its value
deriving (Show, Eq)
-- ============================================================================
-- Command Line
-- ============================================================================
-- | Whether a flag written without @=@ takes the argument after it
data FlagArity
= TakesNext -- ^ Always, whatever it begins with: the parameter takes a value
| TakesBoolean -- ^ Only when it reads as a boolean: the parameter is a boolean
| TakesUnlessFlag -- ^ Unless it begins with @--@ (and is not @--@): no parameter is known
-- | Split the arguments after the backend into path segments, (key, value)
-- pairs, the flag arguments the pairs came from, and whether help was asked
-- for.
--
-- This runs before the method is known, so a flag takes the next argument
-- unless that begins with @--@ and is not exactly @--@. That is enough to find
-- the path. Once the method is known, 'pairFlags' pairs the flag arguments
-- again by type; the pairs returned here serve only where there is no method
-- schema (@_self@, @respond@, a method missing from the IR).
--
-- Example: ["echo", "once", "--message", "hello", "--count", "3"]
-- -> (["echo", "once"], [("message", "hello"), ("count", "3")],
-- ["--message", "hello", "--count", "3"], False)
-- Example: ["echo", "once", "--help"]
-- -> (["echo", "once"], [], [], True)
parsePathAndParams :: [Text] -> ([Text], [(Text, Text)], [Text], Bool)
parsePathAndParams = go [] [] [] False
where
go path params flags helpReq [] = (reverse path, reverse params, reverse flags, helpReq)
go path params flags helpReq (x:xs)
-- --help flag: mark help requested, don't add as param
| x == "--help" || x == "-h" =
go path params flags True xs
-- --key value, --key=value, or a bare --key
| Just flag <- T.stripPrefix "--" x
, not (T.null flag) =
let (key, val, n) = takeFlag (const TakesUnlessFlag) flag xs
(taken, rest) = splitAt n xs
in go path ((normalizeKey key, fromMaybe "" val) : params)
(reverse (x : taken) ++ flags) helpReq rest
-- Regular path segment - split on dots to support Plexus RPC path syntax (e.g., cone.chat)
| otherwise =
let segments = map (T.replace "-" "_") $ filter (not . T.null) $ T.splitOn "." x
in go (reverse segments ++ path) params flags helpReq xs
-- | Pair a method's flag arguments (from 'parsePathAndParams') by the types
-- of its parameters:
--
-- * @--key=value@: everything after the first @=@, empty or not
-- * a boolean: @true@ when bare; it takes the next argument only when that
-- reads as a boolean (@true@, @false@, @1@, @0@), so @--summary --limit 3@
-- is two flags
-- * any other parameter: the next argument, whatever it begins with
-- (@---@, @--force@, @-5@, @--@)
-- * a flag that names no parameter: as in 'parsePathAndParams'; its key
-- stays as typed, so 'UnknownParam' names it so
--
-- Keys are normalized (kebab-case to snake_case); values never are. An
-- argument that no flag takes is an 'UnexpectedArgument'.
pairFlags :: IR -> MethodDef -> [Text] -> Either [ParseError] [(Text, Text)]
pairFlags ir method args = case partitionEithers (go args) of
([], pairs) -> Right pairs
(errs, _) -> Left errs
where
go [] = []
go (x:xs) = case T.stripPrefix "--" x of
Just flag | not (T.null flag) ->
let (typed, val, n) = takeFlag arity flag xs
key = normalizeKey typed
pair = case arity key of
TakesNext -> (key, fromMaybe "" val)
TakesBoolean -> (key, fromMaybe "true" val)
TakesUnlessFlag -> (typed, fromMaybe "" val)
in Right pair : go (drop n xs)
_ -> Left (UnexpectedArgument x) : go xs
arity = flagArity ir method
-- | Read one flag (the text after @--@) against the arguments after it:
-- its key as typed, its value ('Nothing' when bare), and how many of those
-- arguments it took (0 or 1).
takeFlag :: (Text -> FlagArity) -> Text -> [Text] -> (Text, Maybe Text, Int)
takeFlag arity flag next
| (key, eqValue) <- T.breakOn "=" flag, not (T.null eqValue) =
(key, Just (T.drop 1 eqValue), 0)
| otherwise = case (arity (normalizeKey flag), next) of
(TakesNext, val : _) -> (flag, Just val, 1)
(TakesBoolean, val : _) | isBooleanWord val -> (flag, Just val, 1)
(TakesUnlessFlag, val : _) | val == "--" || not ("--" `T.isPrefixOf` val) -> (flag, Just val, 1)
_ -> (flag, Nothing, 0)
where
-- The spellings 'inferTypedValue' reads as a boolean
isBooleanWord val = T.toLower val `elem` ["true", "false", "1", "0"]
-- | How the flag with this (normalized) key takes its value: by the type of
-- the parameter it names, or of the field a dotted key names
flagArity :: IR -> MethodDef -> Text -> FlagArity
flagArity ir method key = case T.splitOn "." key of
name : fields
| Just param <- find ((== name) . pdName) (mdParams method) ->
case foldM (fieldType ir) (pdType param) fields of
Just ty | isBooleanRef ir ty -> TakesBoolean
_ -> TakesNext
_ -> TakesUnlessFlag
-- | The type of a field of a struct, or of any variant of an enum
fieldType :: IR -> TypeRef -> Text -> Maybe TypeRef
fieldType ir ref name = case ref of
RefOptional inner -> fieldType ir inner name
RefNamed qn -> case tdKind <$> Map.lookup (qualifiedNameFull qn) (irTypes ir) of
Just (KindStruct fields) -> fdType <$> find ((== name) . fdName) fields
Just (KindEnum _ variants) -> fdType <$> find ((== name) . fdName) (concatMap vdFields variants)
Just (KindAlias target) -> fieldType ir target name
_ -> Nothing
_ -> Nothing
-- | Is this a boolean (optional, or a named type that is one)?
isBooleanRef :: IR -> TypeRef -> Bool
isBooleanRef ir = \case
RefPrimitive typ _ -> typ == "boolean"
RefOptional inner -> isBooleanRef ir inner
RefNamed qn -> case tdKind <$> Map.lookup (qualifiedNameFull qn) (irTypes ir) of
Just (KindPrimitive typ _) -> typ == "boolean"
Just (KindAlias target) -> isBooleanRef ir target
_ -> False
_ -> False
-- | Normalize a flag key from kebab-case to snake_case
normalizeKey :: Text -> Text
normalizeKey = T.replace "-" "_"
-- ============================================================================
-- 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; for a list of scalars each
-- entry is itself a comma-separated record (objects/lists/any stay whole)
let rawValues = [val | ("", val) <- kvs]
elements val
| isScalarElement ir innerType =
either (Left . MalformedListValue (pdName param) val) Right (splitListValue val)
| otherwise = Right [val]
in if null rawValues
then Left $ InvalidValue (pdName param) "array requires at least one value"
else do
values <- concat <$> traverse elements rawValues
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"
-- | 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, pdDeprecation = Nothing, pdSource = Nothing, pdValues = 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
-- ============================================================================
-- List Values
-- ============================================================================
-- | Is this list element type a scalar (string, integer, number, boolean, or
-- a named type that is one of those)? Only lists of scalars split their flag
-- values on commas; objects, lists and untyped values are passed whole.
isScalarElement :: IR -> TypeRef -> Bool
isScalarElement ir = \case
RefPrimitive typ _ -> typ `elem` scalarTypes
RefNamed qn -> case Map.lookup (qualifiedNameFull qn) (irTypes ir) of
Just TypeDef{tdKind = KindPrimitive typ _} -> typ `elem` scalarTypes
Just TypeDef{tdKind = KindStringEnum _} -> True
Just TypeDef{tdKind = KindAlias target} -> isScalarElement ir target
_ -> False
RefOptional inner -> isScalarElement ir inner
RefArray _ -> False
RefAny -> False
RefUnknown -> False
where
scalarTypes = ["string", "integer", "number", "boolean"]
-- | Split one flag value of a scalar list into its elements.
--
-- The value is one comma-separated record. Unquoted fields are trimmed and
-- dropped when empty. A field starting with @"@ runs to its closing quote,
-- keeps its commas and spaces, and reads @""@ inside as one quote.
--
-- @
-- a,b,c -> ["a", "b", "c"]
-- a, b ,c -> ["a", "b", "c"]
-- a,,b, -> ["a", "b"]
-- "a,b","c" -> ["a,b", "c"]
-- "say ""hi""",b -> ["say \"hi\"", "b"]
-- "",a -> ["", "a"]
-- "a,b or "a"x,b -> Left reason
-- @
splitListValue :: Text -> Either Text [Text]
splitListValue = field
where
field t = case T.uncons (T.stripStart t) of
Just ('"', rest) -> quoted "" rest
_ ->
let (raw, rest) = T.breakOn "," t
val = T.strip raw
in ([val | not (T.null val)] ++) <$> afterComma rest
-- Inside quotes: a doubled quote is one quote, a single one closes the field
quoted acc t = case T.breakOn "\"" t of
(_, "") -> Left "a quoted element has no closing quote"
(chunk, close) -> case T.uncons (T.drop 1 close) of
Just ('"', more) -> quoted (acc <> chunk <> "\"") more
_ -> case T.uncons (T.stripStart (T.drop 1 close)) of
Nothing -> Right [acc <> chunk]
Just (',', more) -> ((acc <> chunk) :) <$> field more
Just _ -> Left "a closing quote must be followed by a comma or the end"
afterComma rest = case T.uncons rest of
Nothing -> Right []
Just (_, more) -> field more -- drop the comma
-- ============================================================================
-- 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