packages feed

ychr-0.1.0.0: src/YCHR/Convert.hs

{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}

-- | An ergonomic bridge between ordinary Haskell data types and CHR
-- terms. Use it when @ychr@ is embedded as a library and you would rather
-- pass and receive Haskell values than hand-build and pattern-match
-- 'Term's.
--
-- This module is a companion to "YCHR.DSL": the DSL builds CHR /programs/,
-- while this module converts /values/ at the program boundary. The library
-- boundary is entirely the pure 'Term' type — a goal is a 'Term', and a
-- result is a @'Map' 'Text' 'Term'@ keyed by goal-variable name — so both
-- classes target 'Term' and never touch the runtime value representation.
--
-- = Worked example
--
-- > {-# LANGUAGE OverloadedStrings #-}
-- > import YCHR.Convert
-- > import YCHR.DSL (module', declaring, defining, term, var, int, (@:), (<=>), (|-))
-- >
-- > -- run a program and decode the "R" binding as a Haskell Int
-- > main = do
-- >   r <- runQuery [myModule] (term "compute" [var "R"]) "R"
-- >   print (r :: Either ConvertError Int)
--
-- = Generic derivation
--
-- Hand-writing instances is optional. Under GHC, "YCHR.Convert.Generic"
-- provides @genericToTerm@ / @genericFromTerm@ so a @deriving 'GHC.Generics.Generic'@
-- type gets instances for free. That module is GHC-only; this one carries
-- no @Generic@ dependency and provides the hand-written path.
module YCHR.Convert
  ( -- * Classes
    ToTerm (..),
    FromTerm (..),

    -- * Errors
    ConvertError (..),

    -- * Combinators for hand-written instances
    compound,
    atomTerm,
    matchCompound,
    decodeSum,
    argAt,
    ground,

    -- * The quote/1 quoting form
    quote,

    -- * Result decoding
    decodeVar,
    decodeVarMaybe,
    lookupBinding,

    -- * Host functions
    -- $hostFunctions
    HostCallFn (..),
    Chr,
    Value (..),
    hostFn0M,
    hostFn1,
    hostFn1M,
    hostFn2,
    hostFn2M,
    hostFn3,
    hostFn3M,
    hostFnN,
    hostFnValues,

    -- * Host-function registries
    HostCallRegistry,
    baseHostCallRegistry,
    hostFunctions,
    withDefaultHostFunctions,

    -- * Typed query wrapper
    runQuery,
    runQueryWith,
    runQueryWithHostCallRegistry,

    -- * Typed query wrapper over a compiled program
    CompiledProgram,
    runQueryCompiled,
    runQueryCompiledWith,
    runQueryCompiledWithHostCallRegistry,
  )
where

import Control.Exception (throwIO)
import Control.Monad.Trans.State.Strict (evalStateT)
import Data.Bits (toIntegralSized)
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Text (Text)
import Data.Text qualified as Text
import YCHR.Internal.Meta (metaHostCallRegistry, termToValue, valueToTerm)
import YCHR.Internal.Parsed (Module)
import YCHR.Internal.Runtime.Error (runtimeErrorS)
import YCHR.Internal.Runtime.Monad (Chr)
import YCHR.Internal.Runtime.Registry (HostCallFn (..), HostCallRegistry, baseHostCallRegistry)
import YCHR.Internal.Runtime.Types (Value (..))
import YCHR.Internal.VM qualified as VM
import YCHR.Run
  ( CompiledProgram,
    Error,
    compileParsedModules,
    runProgramWithGoalDSL,
  )
import YCHR.Types (Constraint (..), Name (..), Term (..))

-- ---------------------------------------------------------------------------
-- Classes
-- ---------------------------------------------------------------------------

-- | Encode a Haskell value as a CHR 'Term'. Total: encoding never fails.
class ToTerm a where
  toTerm :: a -> Term

-- | Decode a CHR 'Term' into a Haskell value. Fallible: the term may have
-- the wrong shape, or be an unbound variable where a ground value was
-- required. Failures are returned as data (a 'ConvertError'), never thrown.
class FromTerm a where
  fromTerm :: Term -> Either ConvertError a

-- ---------------------------------------------------------------------------
-- Errors
-- ---------------------------------------------------------------------------

-- | Why a 'Term' could not be decoded into a Haskell value.
--
-- The constructors are positional rather than record-shaped because the
-- \"what was found\" payload differs across cases ('Term' vs 'Name'), which a
-- single record could not share.
data ConvertError
  = -- | Wrong shape: a description of what was expected, and the term found.
    TypeMismatch Text Term
  | -- | Right functor, wrong argument count: functor, expected arity, found arity.
    ArityMismatch Name Int Int
  | -- | A compound whose functor matched none of a sum type's constructors:
    -- the accepted functor names, and the functor actually found.
    UnknownFunctor [Text] Name
  | -- | A ground value was required but the term was a variable or wildcard.
    UnboundValue Term
  | -- | Result-map decoding: the requested goal variable is absent.
    MissingBinding Text
  | -- | A typed query was given a goal that is not a compound term (a
    -- constraint occurrence). Carries the offending goal term.
    MalformedGoal Term
  deriving (Show, Eq)

-- ---------------------------------------------------------------------------
-- Combinators for hand-written instances
-- ---------------------------------------------------------------------------

-- | Build an unqualified compound term (the same shape as "YCHR.DSL"'s
-- @term@). Handy in 'ToTerm' instances.
compound :: Text -> [Term] -> Term
compound n = CompoundTerm (Unqualified n)

-- | Build a nullary atom (an unqualified 0-arity compound).
atomTerm :: Text -> Term
atomTerm n = CompoundTerm (Unqualified n) []

-- | Wrap a decoder so that an unbound variable or wildcard is reported as
-- 'UnboundValue' before the decoder runs. Every scalar 'FromTerm' instance
-- uses this so \"decode a value from an unbound variable\" fails uniformly.
ground :: (Term -> Either ConvertError a) -> Term -> Either ConvertError a
ground _ t@(VarTerm _) = Left (UnboundValue t)
ground _ t@Wildcard = Left (UnboundValue t)
ground f t = f t

-- | Decode a compound with the given (local) functor name and exact arity,
-- returning its argument terms. Rejects variables/wildcards
-- ('UnboundValue'), a different functor ('UnknownFunctor'), a wrong arity
-- ('ArityMismatch'), and non-compound terms ('TypeMismatch'). The functor is
-- matched on its local part, so a result that comes back module-qualified
-- still decodes.
matchCompound :: Text -> Int -> Term -> Either ConvertError [Term]
matchCompound n arity = ground $ \t -> case t of
  CompoundTerm name args
    | nameLocal name == n ->
        if length args == arity
          then Right args
          else Left (ArityMismatch name arity (length args))
    | otherwise -> Left (UnknownFunctor [n] name)
  _ -> Left (TypeMismatch n t)

-- | Decode a sum type: dispatch on a compound's functor and arity against a
-- table of @(functor, arity, handler)@ rows. Produces 'UnknownFunctor' when
-- no row's functor matches and 'ArityMismatch' when the functor matches but
-- the arity does not.
decodeSum ::
  [(Text, Int, [Term] -> Either ConvertError a)] ->
  Term ->
  Either ConvertError a
decodeSum rows = ground $ \t -> case t of
  CompoundTerm name args ->
    case lookupRow (nameLocal name) of
      Just (ar, h)
        | ar == length args -> h args
        | otherwise -> Left (ArityMismatch name ar (length args))
      Nothing -> Left (UnknownFunctor rowNames name)
  _ -> Left (TypeMismatch (Text.intercalate " | " rowNames) t)
  where
    rowNames = map (\(fn, _, _) -> fn) rows
    lookupRow k =
      foldr (\(fn, ar, h) acc -> if fn == k then Just (ar, h) else acc) Nothing rows

-- | Decode the argument at a 0-based position with 'fromTerm'. Intended for
-- use on the argument list returned by 'matchCompound' \/ 'decodeSum', where
-- the arity has already been checked.
argAt :: (FromTerm a) => Int -> [Term] -> Either ConvertError a
argAt i args = case drop i args of
  (t : _) -> fromTerm t
  [] -> Left (TypeMismatch ("positional argument #" <> Text.pack (show i)) Wildcard)

-- ---------------------------------------------------------------------------
-- The quote/1 quoting form
-- ---------------------------------------------------------------------------

-- | Wrap a value in the @quote\/1@ quoting form, keeping it symbolic.
--
-- Goal and rule-body arguments are /evaluated/, so a compound whose functor
-- is also a declared function is called rather than kept as data. Quoting
-- opts out. Use it where you build the goal, not inside a 'ToTerm' instance:
--
-- > compound "typecheck" [quote expr, VarTerm "Result"]  -- here, or
-- > term     "typecheck" [quote expr, var "Result"]      -- with "YCHR.DSL"
--
-- The argument is any 'ToTerm' value, so the 'toTerm' call is implicit;
-- a plain 'Term' passes through unchanged.
quote :: (ToTerm a) => a -> Term
quote x = CompoundTerm (Unqualified "quote") [toTerm x]

-- | The local (unqualified) part of a name.
nameLocal :: Name -> Text
nameLocal (Unqualified n) = n
nameLocal (Qualified _ n) = n

-- ---------------------------------------------------------------------------
-- Base and stdlib instances
-- ---------------------------------------------------------------------------

instance ToTerm Term where
  toTerm = id

instance FromTerm Term where
  fromTerm = Right

instance ToTerm Integer where
  toTerm = IntTerm

instance FromTerm Integer where
  fromTerm = ground $ \t -> case t of
    IntTerm n -> Right n
    _ -> Left (TypeMismatch "Integer" t)

instance ToTerm Int where
  toTerm = IntTerm . toInteger

instance FromTerm Int where
  fromTerm = ground $ \t -> case t of
    IntTerm n -> maybe (Left (TypeMismatch "Int" t)) Right (toIntegralSized n)
    _ -> Left (TypeMismatch "Int" t)

-- | Strict: an 'IntTerm' is /not/ coerced to a 'Double'. Use 'Integer' \/
-- 'Int' for integral results.
instance ToTerm Double where
  toTerm = FloatTerm

instance FromTerm Double where
  fromTerm = ground $ \t -> case t of
    FloatTerm d -> Right d
    _ -> Left (TypeMismatch "Double" t)

-- | Encodes to the canonical @true@ \/ @false@ atom. Decoding also accepts
-- the @prelude@-qualified forms that appear in results.
instance ToTerm Bool where
  toTerm True = atomTerm "true"
  toTerm False = atomTerm "false"

instance FromTerm Bool where
  fromTerm = ground $ \t -> case t of
    CompoundTerm (Unqualified "true") [] -> Right True
    CompoundTerm (Unqualified "false") [] -> Right False
    CompoundTerm (Qualified "prelude" "true") [] -> Right True
    CompoundTerm (Qualified "prelude" "false") [] -> Right False
    _ -> Left (TypeMismatch "Bool" t)

-- | The idiomatic CHR string type: encodes to 'TextTerm'.
instance ToTerm Text where
  toTerm = TextTerm

instance FromTerm Text where
  fromTerm = ground $ \t -> case t of
    TextTerm s -> Right s
    _ -> Left (TypeMismatch "Text" t)

-- | A single-character 'TextTerm'. Note that @String = [Char]@ therefore
-- round-trips as a /list/ of one-character 'TextTerm's; prefer 'Text' when
-- you want a single 'TextTerm'.
instance ToTerm Char where
  toTerm c = TextTerm (Text.singleton c)

instance FromTerm Char where
  fromTerm = ground $ \t -> case t of
    TextTerm s | Text.length s == 1 -> Right (Text.head s)
    _ -> Left (TypeMismatch "Char" t)

-- | Encodes to the @()@ atom (matching the runtime's unit value).
instance ToTerm () where
  toTerm () = atomTerm "()"

instance FromTerm () where
  fromTerm = ground $ \t -> case t of
    CompoundTerm (Unqualified "()") [] -> Right ()
    _ -> Left (TypeMismatch "()" t)

instance (ToTerm a) => ToTerm (Maybe a) where
  toTerm Nothing = atomTerm "nothing"
  toTerm (Just x) = compound "just" [toTerm x]

instance (FromTerm a) => FromTerm (Maybe a) where
  fromTerm =
    decodeSum
      [ ("nothing", 0, \_ -> Right Nothing),
        ("just", 1, \as -> Just <$> argAt 0 as)
      ]

instance (ToTerm a, ToTerm b) => ToTerm (Either a b) where
  toTerm (Left x) = compound "left" [toTerm x]
  toTerm (Right y) = compound "right" [toTerm y]

instance (FromTerm a, FromTerm b) => FromTerm (Either a b) where
  fromTerm =
    decodeSum
      [ ("left", 1, \as -> Left <$> argAt 0 as),
        ("right", 1, \as -> Right <$> argAt 0 as)
      ]

-- Tuples encode to a @tuple@ compound, distinguished by arity.

instance (ToTerm a, ToTerm b) => ToTerm (a, b) where
  toTerm (a, b) = compound "tuple" [toTerm a, toTerm b]

instance (FromTerm a, FromTerm b) => FromTerm (a, b) where
  fromTerm t = do
    as <- matchCompound "tuple" 2 t
    (,) <$> argAt 0 as <*> argAt 1 as

instance (ToTerm a, ToTerm b, ToTerm c) => ToTerm (a, b, c) where
  toTerm (a, b, c) = compound "tuple" [toTerm a, toTerm b, toTerm c]

instance (FromTerm a, FromTerm b, FromTerm c) => FromTerm (a, b, c) where
  fromTerm t = do
    as <- matchCompound "tuple" 3 t
    (,,) <$> argAt 0 as <*> argAt 1 as <*> argAt 2 as

instance (ToTerm a, ToTerm b, ToTerm c, ToTerm d) => ToTerm (a, b, c, d) where
  toTerm (a, b, c, d) = compound "tuple" [toTerm a, toTerm b, toTerm c, toTerm d]

instance (FromTerm a, FromTerm b, FromTerm c, FromTerm d) => FromTerm (a, b, c, d) where
  fromTerm t = do
    as <- matchCompound "tuple" 4 t
    (,,,) <$> argAt 0 as <*> argAt 1 as <*> argAt 2 as <*> argAt 3 as

-- | Prolog list encoding: cons is @.\/2@, nil is @[]@. Interoperates with
-- the @lists@ library. Decoding also accepts the @prelude@-qualified and
-- mangled cons\/nil forms that can appear in results.
instance (ToTerm a) => ToTerm [a] where
  toTerm = foldr (\x acc -> compound "." [toTerm x, acc]) (atomTerm "[]")

instance (FromTerm a) => FromTerm [a] where
  fromTerm = ground go
    where
      go t = case t of
        CompoundTerm name []
          | isNil name -> Right []
        CompoundTerm name [h, tl]
          | isCons name -> (:) <$> fromTerm h <*> fromTerm tl
        _ -> Left (TypeMismatch "list" t)
      isNil name =
        name
          `elem` [ Unqualified "[]",
                   Qualified "prelude" "[]",
                   Unqualified "prelude__[]"
                 ]
      isCons name =
        name
          `elem` [ Unqualified ".",
                   Qualified "prelude" ".",
                   Unqualified "prelude__."
                 ]

-- ---------------------------------------------------------------------------
-- Result decoding
-- ---------------------------------------------------------------------------

-- | Decode a single goal variable's binding by name. 'MissingBinding' when
-- the variable is absent from the result map; otherwise delegates to
-- 'fromTerm'.
decodeVar :: (FromTerm a) => Text -> Map Text Term -> Either ConvertError a
decodeVar k m = case Map.lookup k m of
  Nothing -> Left (MissingBinding k)
  Just t -> fromTerm t

-- | Like 'decodeVar' but yields 'Nothing' for an absent variable instead of
-- failing. A present-but-undecodable binding still fails.
decodeVarMaybe :: (FromTerm a) => Text -> Map Text Term -> Either ConvertError (Maybe a)
decodeVarMaybe k m = case Map.lookup k m of
  Nothing -> Right Nothing
  Just t -> Just <$> fromTerm t

-- | Raw lookup escape hatch: the bound 'Term' for a goal variable, if any.
lookupBinding :: Text -> Map Text Term -> Maybe Term
lookupBinding = Map.lookup

-- ---------------------------------------------------------------------------
-- Typed query wrapper
-- ---------------------------------------------------------------------------

-- | Compile the modules, run the goal 'Term', and decode a single goal
-- variable's binding as a Haskell value. The goal is built exactly like a
-- rule head or "YCHR.DSL" body goal (e.g. @term \"leq\" [int 1, var \"R\"]@);
-- its 'ToTerm'-encoded arguments run at tell time.
--
-- Compilation failures are thrown as 'Error' (as 'YCHR.DSL.runDSL' does);
-- decoding failures are returned as 'Left'. Uses the base + meta host-call
-- registries and includes the standard library.
runQuery :: (FromTerm a) => [Module] -> Term -> Text -> IO (Either ConvertError a)
runQuery modules goal v = runQueryWith modules goal (decodeVar v)

-- | Like 'runQuery' but takes an explicit decoder over the whole binding
-- map, so a record can be assembled from several 'decodeVar' calls.
runQueryWith ::
  [Module] ->
  Term ->
  (Map Text Term -> Either ConvertError a) ->
  IO (Either ConvertError a)
runQueryWith = runQueryWithHostCallRegistry (baseHostCallRegistry <> metaHostCallRegistry)

-- | Like 'runQueryWith' but takes an explicit host-call registry. Use this
-- when the program calls custom @host:_@ functions registered by the
-- embedder.
runQueryWithHostCallRegistry ::
  HostCallRegistry ->
  [Module] ->
  Term ->
  (Map Text Term -> Either ConvertError a) ->
  IO (Either ConvertError a)
runQueryWithHostCallRegistry hostCalls modules goal decode =
  -- Check the goal shape before compiling, so a malformed goal is reported
  -- as data without doing (or throwing on) the compile.
  case goalConstraint goal of
    Left err -> pure (Left err)
    Right _ -> do
      cp <- compileOrThrow modules
      runQueryCompiledWithHostCallRegistry hostCalls cp goal decode

compileOrThrow :: [Module] -> IO CompiledProgram
compileOrThrow modules = case compileParsedModules True modules of
  Left err -> throwIO (err :: Error)
  Right (cp, _warnings) -> pure cp

-- ---------------------------------------------------------------------------
-- Typed query wrapper over a compiled program
-- ---------------------------------------------------------------------------

-- | Like 'runQuery' but over an already-'CompiledProgram' instead of a
-- list of source modules. Compile once (with 'YCHR.Run.compileFiles' for
-- @.chr@ files, or 'YCHR.Run.compileParsedModules' for "YCHR.DSL"
-- modules), then run as many typed queries as you like against the same
-- program — each call is an independent run with a fresh store. This is
-- the entry point for embedding a real @.chr@ module and driving it with
-- 'ToTerm' \/ 'FromTerm'.
runQueryCompiled ::
  (FromTerm a) => CompiledProgram -> Term -> Text -> IO (Either ConvertError a)
runQueryCompiled cp goal v = runQueryCompiledWith cp goal (decodeVar v)

-- | Like 'runQueryWith' but over an already-'CompiledProgram'. Decodes the
-- whole binding map, so a record can be assembled from several 'decodeVar'
-- calls.
runQueryCompiledWith ::
  CompiledProgram ->
  Term ->
  (Map Text Term -> Either ConvertError a) ->
  IO (Either ConvertError a)
runQueryCompiledWith =
  runQueryCompiledWithHostCallRegistry (baseHostCallRegistry <> metaHostCallRegistry)

-- | Like 'runQueryWithHostCallRegistry' but over an
-- already-'CompiledProgram'. Use this when the program calls custom
-- @host:_@ functions registered by the embedder.
runQueryCompiledWithHostCallRegistry ::
  HostCallRegistry ->
  CompiledProgram ->
  Term ->
  (Map Text Term -> Either ConvertError a) ->
  IO (Either ConvertError a)
runQueryCompiledWithHostCallRegistry hostCalls cp goal decode =
  case goalConstraint goal of
    Left err -> pure (Left err)
    Right constraint -> do
      bindings <- runProgramWithGoalDSL cp hostCalls constraint
      pure (decode bindings)

-- | A goal must be a compound term (a constraint occurrence). Unlike
-- "YCHR.DSL"'s @termToConstraint@, which crashes, this reports a malformed
-- goal as a 'ConvertError' so it flows through the errors-as-data query API.
goalConstraint :: Term -> Either ConvertError Constraint
goalConstraint (CompoundTerm n args) = Right (Constraint n args)
goalConstraint t = Left (MalformedGoal t)

-- ---------------------------------------------------------------------------
-- Host functions
-- ---------------------------------------------------------------------------

-- $hostFunctions
--
-- A @host:f(args)@ call in a CHR program is dispatched through a
-- 'HostCallRegistry'. The adapters below lift ordinary Haskell functions
-- into 'HostCallFn' entries using the same 'ToTerm' \/ 'FromTerm' classes
-- used for goals and results, so the common case needs no knowledge of the
-- runtime value representation. Assemble a registry with 'hostFunctions'
-- (or 'withDefaultHostFunctions') and pass it to
-- 'runQueryWithHostCallRegistry' \/ 'runQueryCompiledWithHostCallRegistry'.

-- | Marshal one dereferenced host-call argument 'Value' into a decoded
-- Haskell value. 'valueToTerm' recursively dereferences, so a logical
-- variable bound inside a compound argument is resolved; a genuinely
-- unbound argument becomes 'Wildcard' and 'fromTerm' rejects it as an
-- 'UnboundValue'.
argFromValue :: (FromTerm a) => Value -> Chr (Either ConvertError a)
argFromValue v = fromTerm <$> valueToTerm Map.empty v

-- | Marshal a host-function result Haskell value back into a runtime
-- 'Value'. Results are expected ground.
resultToValue :: (ToTerm r) => r -> Chr Value
resultToValue r = evalStateT (termToValue (toTerm r)) Map.empty

hostArityError :: Int -> [a] -> Chr b
hostArityError n vs =
  runtimeErrorS
    ("host call: expected " ++ show n ++ " argument(s), got " ++ show (length vs))

hostDecodeError :: ConvertError -> Chr a
hostDecodeError err = runtimeErrorS ("host call: " ++ show err)

-- | Adapt a nullary effectful action into a host function. There is no
-- pure @hostFn0@ because a nullary pure host function is just a constant;
-- use this for host calls that read external state (a clock, a fresh
-- identifier) or perform I\/O. The body runs in 'Chr' (use 'liftIO' for
-- 'IO').
hostFn0M :: (ToTerm r) => Chr r -> HostCallFn
hostFn0M act = HostCallFn $ \case
  [] -> act >>= resultToValue
  vs -> hostArityError 0 vs

-- | Adapt a pure unary Haskell function into a host function.
--
-- > hostFunctions [("shout", hostFn1 Data.Text.toUpper)]   -- host:shout(X)
hostFn1 :: (FromTerm a, ToTerm r) => (a -> r) -> HostCallFn
hostFn1 f = hostFn1M (pure . f)

-- | Effectful unary adapter: the body runs in 'Chr', so it may perform
-- I\/O (via 'liftIO'), dereference logical variables, or inspect the
-- constraint store. Arguments and result still marshal via
-- 'FromTerm' \/ 'ToTerm'.
hostFn1M :: (FromTerm a, ToTerm r) => (a -> Chr r) -> HostCallFn
hostFn1M f = HostCallFn $ \case
  [va] -> do
    ea <- argFromValue va
    case ea of
      Right a -> f a >>= resultToValue
      Left err -> hostDecodeError err
  vs -> hostArityError 1 vs

-- | Adapt a pure binary Haskell function into a host function.
--
-- > hostFunctions [("my_add", hostFn2 ((+) :: Int -> Int -> Int))]  -- host:my_add(X, Y)
hostFn2 :: (FromTerm a, FromTerm b, ToTerm r) => (a -> b -> r) -> HostCallFn
hostFn2 f = hostFn2M (\a b -> pure (f a b))

-- | Effectful binary adapter. See 'hostFn1M'.
hostFn2M :: (FromTerm a, FromTerm b, ToTerm r) => (a -> b -> Chr r) -> HostCallFn
hostFn2M f = HostCallFn $ \case
  [va, vb] -> do
    ea <- argFromValue va
    eb <- argFromValue vb
    case (,) <$> ea <*> eb of
      Right (a, b) -> f a b >>= resultToValue
      Left err -> hostDecodeError err
  vs -> hostArityError 2 vs

-- | Adapt a pure ternary Haskell function into a host function.
hostFn3 ::
  (FromTerm a, FromTerm b, FromTerm c, ToTerm r) =>
  (a -> b -> c -> r) ->
  HostCallFn
hostFn3 f = hostFn3M (\a b c -> pure (f a b c))

-- | Effectful ternary adapter. See 'hostFn1M'.
hostFn3M ::
  (FromTerm a, FromTerm b, FromTerm c, ToTerm r) =>
  (a -> b -> c -> Chr r) ->
  HostCallFn
hostFn3M f = HostCallFn $ \case
  [va, vb, vc] -> do
    ea <- argFromValue va
    eb <- argFromValue vb
    ec <- argFromValue vc
    case (,,) <$> ea <*> eb <*> ec of
      Right (a, b, c) -> f a b c >>= resultToValue
      Left err -> hostDecodeError err
  vs -> hostArityError 3 vs

-- | Variable-arity escape hatch that still marshals through 'Term'. The
-- supplied function receives every argument already decoded to a 'Term'
-- (recursively dereferenced) and returns the result 'Term' or a
-- 'ConvertError'. Use it for host functions whose arity is not fixed
-- (e.g. an n-ary sum).
hostFnN :: ([Term] -> Either ConvertError Term) -> HostCallFn
hostFnN g = HostCallFn $ \vs -> do
  ts <- traverse (valueToTerm Map.empty) vs
  case g ts of
    Right t -> resultToValue t
    Left err -> hostDecodeError err

-- | The raw host-function escape hatch: build a 'HostCallFn' directly from
-- @'Value' -> 'Chr' 'Value'@, with no 'Term' marshalling. Arguments arrive
-- top-level dereferenced only (logical variables nested inside a compound
-- argument are /not/ chased — use 'YCHR.Run.deref' as needed). This is the
-- 'HostCallFn' constructor under a descriptive name, exposed so the raw
-- path needs no import of the internal runtime modules.
hostFnValues :: ([Value] -> Chr Value) -> HostCallFn
hostFnValues = HostCallFn

-- | Assemble a host-call registry from named host functions. The names are
-- the bare functors used at the call site: an entry @("my_add", …)@ is
-- invoked as @host:my_add(...)@ from CHR source. Composes with '<>'.
hostFunctions :: [(Text, HostCallFn)] -> HostCallRegistry
hostFunctions = Map.fromList . map (\(n, fn) -> (VM.Name n, fn))

-- | Like 'hostFunctions', but the given functions are unioned over the full
-- default registry (the base arithmetic \/ comparison \/ string builtins
-- plus the meta operations), so a program can call both the built-ins and
-- the custom functions. On a name clash the custom entry wins.
withDefaultHostFunctions :: [(Text, HostCallFn)] -> HostCallRegistry
withDefaultHostFunctions fns =
  hostFunctions fns <> baseHostCallRegistry <> metaHostCallRegistry