packages feed

ychr-0.1.0.0: src/YCHR/Run.hs

{-# LANGUAGE OverloadedRecordDot #-}
{-# LANGUAGE OverloadedStrings #-}

-- | Top-level orchestration: compile a program, then run goals or
-- multi-goal queries against it. The CHR session machinery lives in
-- "YCHR.Internal.Runtime.Session"; the compilation pipeline lives in
-- "YCHR.Internal.Compile.Pipeline". This module ties the two together and
-- adds the query-time goal evaluator used by 'runProgramWithQuery'
-- and the live REPL session in "YCHR.Internal.Repl".
module YCHR.Run
  ( -- * Compilation (re-exported from "YCHR.Internal.Compile.Pipeline")
    Error (..),
    GoalRejection (..),
    Warning (..),
    CompiledProgram,
    compileModules,
    compileFiles,
    compileParsedModules,

    -- * Rendering diagnostics
    displayError,
    displayWarning,

    -- * Running goals
    runProgramWithGoal,
    runProgramWithGoalDSL,
    runProgramWithQuery,

    -- * CHR sessions
    Chr,
    withCHR,
    withTraceHandler,
    tellConstraint,

    -- * Runtime values
    Value (..),
    newVar,
    deref,
    equal,
    unify,

    -- * Query pipeline
    -- $queryPipeline
    ExportResolution (..),
    resolveQueryConstraint,
    resolveQueryTellOrThrow,
    prepareGoal,
    goalShapeConstraint,
    runPreparedGoal,
    PreparedQuery (..),
    prepareQuery,
    executePreparedQuery,
    withCHRExtraTraced,
    toSessionInput,
  )
where

import Control.Exception
  ( SomeAsyncException,
    SomeException,
    displayException,
    fromException,
    handle,
    throwIO,
    try,
  )
import Control.Monad (unless, void, when)
import Control.Monad.IO.Class (liftIO)
import Control.Monad.Trans.Class (lift)
import Control.Monad.Trans.Reader (ask, runReaderT)
import Control.Monad.Trans.State.Strict (StateT, evalStateT, get, modify)
import Control.Monad.Trans.Writer.CPS (runWriter)
import Data.IORef (readIORef)
import Data.List.NonEmpty (NonEmpty (..))
import Data.List.NonEmpty qualified as NE
import Data.Map.Strict (Map)
import Data.Map.Strict qualified as Map
import Data.Set qualified as Set
import Data.Text (Text)
import Data.Text qualified as T
import YCHR.Internal.Compile
  ( compileFunctionDef,
    funcProcName,
    genCallFunDispatches,
    vmName,
  )
import YCHR.Internal.Compile.Pipeline
  ( CompiledProgram (..),
    Error (..),
    ExportResolution (..),
    GoalRejection (..),
    Warning (..),
    compileFiles,
    compileModules,
    compileParsedModules,
  )
import YCHR.Internal.Desugar (desugarQueryGoals, liftQueryLambdas)
import YCHR.Internal.Desugared qualified as D
import YCHR.Internal.Diagnostic (Diagnostic)
import YCHR.Internal.Display (displayMsg)
import YCHR.Internal.Meta (valueToTerm)
import YCHR.Internal.PExpr (PExpr (Atom))
import YCHR.Internal.Parsed (AnnP (..), SourceLoc (..))
import YCHR.Internal.Parser (ParseValidationError (..), parseConstraintWith, parseQueryWith)
import YCHR.Internal.Pretty (prettyPExprSrc, prettyTerm)
import YCHR.Internal.Rename (renameQueryArgs, renameQueryGoals)
import YCHR.Internal.Resolve (ResolveError, termToExpr)
import YCHR.Internal.Resolved qualified as R
import YCHR.Internal.Runtime.Error (RuntimeErrorThrown (..), runtimeErrorS)
import YCHR.Internal.Runtime.Interpreter
  ( HostCallFn (..),
    HostCallRegistry,
    callProc,
    constraintTypeLabel,
    deepEvalValue,
    emitTrace,
    snapshotValue,
    snapshotValues,
    suspensionView,
  )
import YCHR.Internal.Runtime.Monad (Chr, SessionEnv (..))
import YCHR.Internal.Runtime.Reactivation (drainQueue, enqueue)
import YCHR.Internal.Runtime.Session
  ( tellConstraint,
    toSessionInput,
    withCHR,
    withCHRExtra,
    withCHRExtraTraced,
    withTraceHandler,
  )
import YCHR.Internal.Runtime.Store (aliveConstraint)
import YCHR.Internal.Runtime.Trace (TraceEvent (..))
import YCHR.Internal.Runtime.Types (CallVal (..), Value (..), VarId)
import YCHR.Internal.Runtime.Var (deref, equal, getVarId, newVar, unify)
import YCHR.Internal.TypeCheck (typeCheckGoals)
import YCHR.Internal.Types (Constraint (..), Term (..))
import YCHR.Internal.Types qualified as Types
import YCHR.Internal.VM (Name (..), Procedure (..))

-- ---------------------------------------------------------------------------
-- Single-goal API
-- ---------------------------------------------------------------------------

-- | Resolve a query constraint against the export map. The resolved
-- form is a 'Types.QualifiedConstraint' since name resolution always
-- produces a fully-qualified name. On failure, returns a structured
-- 'GoalRejection' so 'resolveQueryTellOrThrow' can surface a
-- @YCHR-NNNNN@-coded diagnostic. The rejection only covers
-- name-resolution failures; the post-resolution check that the
-- resolved name actually refers to a constraint (and not a function)
-- lives in 'resolveQueryTellOrThrow', which has the desugared program
-- in hand.
resolveQueryConstraint ::
  CompiledProgram ->
  Constraint ->
  Either GoalRejection Types.QualifiedConstraint
resolveQueryConstraint cp (Constraint cname cargs) = case cname of
  Types.Unqualified n ->
    let arity = length cargs
     in case Map.lookup (Types.UnqualifiedIdentifier n arity) cp.exportMap of
          Just (UniqueExport qname) ->
            Right (Types.QualifiedConstraint qname cargs)
          Just (AmbiguousExport ms) ->
            Left (AmbiguousConstraint ms)
          Nothing -> Left NoSuchConstraint
  Types.Qualified m n ->
    let arity = length cargs
     in if Set.member (Types.QualifiedIdentifier m n arity) cp.exportedSet
          then Right (Types.QualifiedConstraint (Types.QualifiedName m n) cargs)
          else Left (ConstraintNotExported (Types.QualifiedName m n))

-- | Resolve a query constraint to its qualified name and 'Expr'-typed
-- arguments. The arguments are lifted from the surface 'Term' shape via
-- 'termToExpr', so they are evaluated like any other tell-side
-- argument when the goal runs. The outer 'Either' carries
-- name-resolution failures (in the same string format as
-- 'resolveQueryConstraint'); the inner diagnostic list collects any
-- non-fatal resolve errors emitted while typing the arguments.
resolveQueryTell ::
  CompiledProgram ->
  Constraint ->
  Either GoalRejection ((Types.QualifiedName, [R.Expr]), [Diagnostic ResolveError])
resolveQueryTell cp c = do
  qc <- resolveQueryConstraint cp c
  let (exprs, errs) =
        runWriter
          (traverse (termToExpr cp.queryFunctionVisibility queryLoc queryOrigin) qc.args)
  pure ((qc.name, exprs), errs)

-- | Run a single CHR constraint against a compiled program. Returns
-- the per-query variable bindings.
runProgramWithGoalDSL ::
  CompiledProgram ->
  HostCallRegistry ->
  Constraint ->
  IO (Map Text Term)
runProgramWithGoalDSL cp hostCalls constraint = convertRuntimeError $ do
  (qn, exprs) <- resolveQueryTellOrThrow cp constraint
  let (lifted, lambdas, liftErrs) =
        liftQueryLambdas cp.nextLambdaIndex [D.BodyTell qn exprs]
  unless (null liftErrs) (throwIO (DesugarErrors liftErrs))
  let queryProcs = compileQueryLambdas lambdas
      allFuns = cp.allFunctions ++ lambdas
      queryDispatches = genCallFunDispatches allFuns
      extraProcs = queryProcs ++ queryDispatches
  withCHRExtra (toSessionInput cp) hostCalls extraProcs $
    executePreparedQuery lifted

-- | Resolve a goal and throw on any failure. Used by both
-- 'runProgramWithGoalDSL' and 'runPreparedGoal'.
--
-- A name-resolution failure (or a name that resolves to a function
-- rather than a constraint) becomes 'GoalNotAConstraint', so the CLI's
-- single-constraint goal surface surfaces a @YCHR-20013@ diagnostic
-- with a hint pointing at the REPL.
resolveQueryTellOrThrow ::
  CompiledProgram -> Constraint -> IO (Types.QualifiedName, [R.Expr])
resolveQueryTellOrThrow cp c = case resolveQueryTell cp c of
  Left rejection -> throwIO (GoalNotAConstraint c rejection)
  Right ((qn, exprs), errs)
    | not (Map.member qn cp.desugaredProgram.constraintTypes) ->
        throwIO (GoalNotAConstraint c (NotAConstraintItem qn))
    | otherwise -> do
        unless (null errs) (throwIO (ResolveErrors errs))
        pure (qn, exprs)

queryLoc :: SourceLoc
queryLoc = SourceLoc "<query>" 1 1

queryOrigin :: PExpr
queryOrigin = Atom ""

-- $queryPipeline
-- The staged internals behind 'runProgramWithGoal' and
-- 'runProgramWithQuery': resolve a goal, prepare it, then execute it.
-- They exist so the REPL can interleave its own work between the
-- stages, and are exported for the same reason the @YCHR.Internal@
-- modules are.
--
-- __These are not covered by the package version policy.__ Several of
-- them mention types from @YCHR.Internal.*@ in their signatures, which
-- is the giveaway. Use 'runProgramWithGoal', 'runProgramWithQuery', or
-- the typed wrappers in "YCHR.Convert" unless you specifically need to
-- drive the stages yourself.

-- | Render an 'Error' the way the @ychr@ command-line tool does:
-- @file:line:col:@ prefix, the @YCHR-NNNNN@ code, the message, and the
-- offending source line where one is available.
--
-- Prefer this to 'show': the derived 'Show' instance dumps the internal
-- diagnostic representation, whereas this is the supported, stable
-- rendering. The @YCHR-NNNNN@ codes are covered by the package version
-- policy and catalogued in
-- <https://github.com/lortabac/ychr/blob/master/docs/reference/errors.md>.
--
-- The result is a 'String' (not 'Data.Text.Text') because it is meant to
-- go straight to a handle:
--
-- > case compileModules True mods of
-- >   Left err -> hPutStr stderr (displayError err)
-- >   Right (cp, ws) -> mapM_ (hPutStr stderr . displayWarning) ws >> ...
--
-- __The result contains ANSI colour escapes__, unconditionally — there is
-- no terminal detection and no @NO_COLOR@ handling yet. That suits a
-- terminal, but strip them before putting the string in a log file, a JSON
-- payload, or a test assertion.
displayError :: Error -> String
displayError = displayMsg

-- | Render a 'Warning' in the same format as 'displayError'.
displayWarning :: Warning -> String
displayWarning = displayMsg

-- | Re-throw 'RuntimeErrorThrown' (from the runtime layer) as the
-- user-facing 'RuntimeError' constructor of 'Error'. Applied at the
-- top-level IO entry points so callers can pattern-match a single
-- exception type ('Error') without depending on the runtime's
-- internal exception.
convertRuntimeError :: IO a -> IO a
convertRuntimeError = handle $ \(RuntimeErrorThrown msg stack) ->
  throwIO (RuntimeError msg stack)

-- | 'Chr'-flavored version of 'convertRuntimeError', applied at the
-- 'executePreparedQuery' boundary so the REPL's catch helpers see a
-- uniform 'Error' value regardless of which path raised it.
convertRuntimeErrorChr :: Chr a -> Chr a
convertRuntimeErrorChr m = do
  env <- ask
  liftIO $
    handle (\(RuntimeErrorThrown msg stack) -> throwIO (RuntimeError msg stack)) $
      runReaderT m env

-- | Parse and rename a goal, returning the canonicalized 'Constraint'
-- alongside any rename warnings. Throws on parse or rename errors.
-- Splitting this out lets the CLI surface goal-argument warnings before
-- the goal runs (notably for @--Werror@).
prepareGoal :: CompiledProgram -> Text -> IO (Constraint, [Warning])
prepareGoal cp src = case parseConstraintWith cp.opTable "<query>" src of
  Left err -> throwIO (ParseError "<query>" err)
  Right parsed -> case either goalShapeConstraint Right parsed of
    Left validErr -> throwIO (ParseValidationErrors [validErr])
    Right (Constraint cname cargs) -> do
      (renamedArgs, ws) <-
        either
          (throwIO . RenameErrors)
          pure
          (renameQueryArgs cp.allModules cargs)
      let warnings = [RenameWarnings ws | not (null ws)]
      pure (Constraint cname renamedArgs, warnings)

-- | Recover a 'Constraint' from a goal-parse validation error.
-- 'convertConstraint' rejects goals that are not constraint-shaped (a
-- bare literal, variable, or wildcard) with 'MalformedConstraint'. For a
-- /goal/ (unlike a rule head) this is the same failure category as
-- @1 + 1@ or @a, b@: synthesize a 0-arity goal name from the offending
-- term so the normal name-resolution path rejects it as
-- 'NoSuchConstraint' (YCHR-20013) at the same stage and with the same
-- code as other non-constraint goals — mirroring how the bare-atom goal
-- @true@ renders as @Goal \'true\/0\'@ — instead of the YCHR-15003
-- 'MalformedConstraint' reserved for malformed rule heads. The catch-all
-- keeps any other validation error (none are emitted today) on its
-- original path.
goalShapeConstraint ::
  AnnP ParseValidationError -> Either (AnnP ParseValidationError) Constraint
goalShapeConstraint (AnnP MalformedConstraint _ pexpr) =
  Right (Constraint (Types.Unqualified (T.pack (prettyPExprSrc pexpr))) [])
goalShapeConstraint other = Left other

-- | Type-check and run a previously prepared single-goal constraint.
-- Throws 'TypeErrors' on goal-time type errors. Returns the per-query
-- variable bindings.
runPreparedGoal ::
  CompiledProgram ->
  HostCallRegistry ->
  Constraint ->
  IO (Map Text Term)
runPreparedGoal cp hostCalls original = do
  tcErrs <- case resolveQueryTell cp original of
    Right ((qn, exprs), errs)
      | null errs ->
          typeCheckGoals
            cp.desugaredProgram
            queryLoc
            (Just "query")
            [D.BodyTell qn exprs]
    -- Skip type-checking if name resolution failed or termToExpr
    -- raised diagnostics; the runtime path will surface the same
    -- errors with the same messages.
    _ -> pure []
  unless (null tcErrs) (throwIO (TypeErrors tcErrs))
  runProgramWithGoalDSL cp hostCalls original

-- | Like 'runProgramWithGoalDSL' but accepts a query as surface-language 'Text'.
runProgramWithGoal ::
  CompiledProgram ->
  HostCallRegistry ->
  Text ->
  IO (Map Text Term)
runProgramWithGoal cp hostCalls src = do
  (constraint, _ws) <- prepareGoal cp src
  runPreparedGoal cp hostCalls constraint

-- ---------------------------------------------------------------------------
-- Multi-goal query API
-- ---------------------------------------------------------------------------

-- | Result of parsing, desugaring, lambda-lifting, and type-checking
-- a query — everything that can be done before entering the CHR effect
-- stack. 'queryLambdas' is non-empty iff the query introduced anonymous
-- @fun(...) -> ... end@ expressions; 'extraProcs' must be added to the
-- 'ProcMap' before executing the query.
data PreparedQuery = PreparedQuery
  { liftedGoals :: [D.BodyGoal],
    queryLambdas :: [D.Function],
    extraProcs :: [Procedure]
  }

-- | Parse, rename, desugar, lambda-lift, and type-check a query.
prepareQuery :: CompiledProgram -> Text -> IO (PreparedQuery, [Warning])
prepareQuery cp src = do
  goals <-
    either
      (throwIO . ParseError "<query>")
      pure
      ( parseQueryWith
          cp.opTable
          "<query>"
          src
      )
  (renamed, renameWs) <-
    either
      (throwIO . RenameErrors)
      pure
      ( renameQueryGoals
          cp.allModules
          goals
      )
  let vis = cp.queryFunctionVisibility
      (exprs, exprErrs) =
        runWriter (traverse (termToExpr vis queryLoc queryOrigin) renamed)
  unless (null exprErrs) (throwIO (ResolveErrors exprErrs))
  bodyGoals <-
    either
      (throwIO . DesugarErrors)
      pure
      (desugarQueryGoals exprs)
  let (lifted, lambdas, liftErrs) = liftQueryLambdas cp.nextLambdaIndex bodyGoals
  unless (null liftErrs) (throwIO (DesugarErrors liftErrs))
  let cdp = cp.desugaredProgram
      progForCheck =
        D.Program
          { rules = cdp.rules,
            functions = cdp.functions ++ lambdas,
            constraintTypes = cdp.constraintTypes,
            constraintBounds = cdp.constraintBounds,
            typeDefinitions = cdp.typeDefinitions
          }
  tcErrs <- typeCheckGoals progForCheck queryLoc (Just "query") lifted
  unless (null tcErrs) (throwIO (TypeErrors tcErrs))
  let allFuns = cp.allFunctions ++ lambdas
      queryProcs = compileQueryLambdas lambdas
      queryDispatches = genCallFunDispatches allFuns
      warnings = [RenameWarnings renameWs | not (null renameWs)]
  pure
    ( PreparedQuery
        { liftedGoals = lifted,
          queryLambdas = lambdas,
          extraProcs = queryProcs ++ queryDispatches
        },
      warnings
    )

-- | Execute the lifted goals of a 'PreparedQuery' inside an existing CHR
-- session. Opens its own per-query variable scope and returns the
-- resulting bindings. The host-call registry is read from the ambient
-- 'SessionEnv'; the action only needs the goals.
executePreparedQuery :: [D.BodyGoal] -> Chr (Map Text Term)
executePreparedQuery lifted =
  convertRuntimeErrorChr $
    evalStateT
      ( do
          mapM_ executeBodyGoal lifted
          varMap <- get
          classes <- lift (buildAliasClasses varMap)
          lift $
            Map.traverseWithKey
              (\k v -> valueToTerm (perKeyAliases classes k) v)
              varMap
      )
      (Map.empty :: Map Text Value)

-- | Group the user-visible query variables by their underlying
-- 'VarId'. Each class is non-empty by construction: a fresh class
-- starts as a one-element 'NonEmpty', and subsequent variables sharing
-- the same 'VarId' are appended.
buildAliasClasses :: Map Text Value -> Chr (Map VarId (NonEmpty Text))
buildAliasClasses varMap = do
  pairs <- traverse vidOf (Map.toAscList varMap)
  pure $ Map.fromListWith (flip (<>)) [(vid, k :| []) | (k, Just vid) <- pairs]
  where
    vidOf (k, v)
      | "_" `T.isPrefixOf` k = pure (k, Nothing)
      | otherwise = do
          mvid <- getVarId v
          pure (k, mvid)

-- | Build the 'VarId' → display name map that 'valueToTerm' should
-- use when printing the binding for surface variable @k@. A singleton
-- alias class contributes nothing (no aliasing); otherwise we pick the
-- name that follows @k@ in the class, wrapping back to the canonical
-- (head) name if @k@ is at the end of, or absent from, the class.
perKeyAliases :: Map VarId (NonEmpty Text) -> Text -> Map VarId Text
perKeyAliases classes k = Map.mapMaybe pick classes
  where
    pick (_ :| []) = Nothing
    pick names@(canonical :| _) = Just $ case break (== k) (NE.toList names) of
      (_, _ : next : _) -> next
      (_, [_]) -> canonical
      (_, []) -> canonical

-- | Run a multi-goal query against a compiled program.
runProgramWithQuery :: CompiledProgram -> HostCallRegistry -> Text -> IO (Map Text Term)
runProgramWithQuery cp hostCalls src = do
  (prep, _ws) <- prepareQuery cp src
  withCHRExtra (toSessionInput cp) hostCalls prep.extraProcs $
    executePreparedQuery prep.liftedGoals

-- ---------------------------------------------------------------------------
-- Query goal evaluator (internal)
-- ---------------------------------------------------------------------------

type QueryM = StateT (Map Text Value) Chr

-- | Resolve a surface 'Term' to a 'Value' inside the per-query
-- variable scope, allocating a fresh logical variable for each new
-- 'VarTerm' the query introduces.
termToValue :: Term -> QueryM Value
termToValue (VarTerm n) = do
  varMap <- get
  case Map.lookup n varMap of
    Just v -> pure v
    Nothing -> do
      v <- lift newVar
      modify (Map.insert n v)
      pure v
termToValue (IntTerm n) = pure (VInt n)
termToValue (FloatTerm n) = pure (VFloat n)
termToValue (TextTerm s) = pure (VText s)
termToValue Wildcard = pure VWildcard
-- Native-bool fast path. Mirrors 'Compile.compileTerm' for
-- @prelude:true@/@prelude:false@: the @=@-operand lowering must
-- produce 'VBool' so structural unification with comparison results
-- (which return 'VBool' directly) succeeds.
termToValue (CompoundTerm (Types.Qualified "prelude" "true") []) = pure (VBool True)
termToValue (CompoundTerm (Types.Qualified "prelude" "false") []) = pure (VBool False)
-- 0-arity ctors collapse to atoms at the runtime layer. Qualified
-- 0-arity uses the @vmName@-mangled @m__n@ form; unqualified 0-arity
-- (user-quoted atoms, undeclared bare names) keeps the raw name.
-- See 'YCHR.Internal.Compile.compileTerm' for the rationale.
termToValue (CompoundTerm name@(Types.Qualified _ _) []) =
  pure (VAtom (vmName name).unName)
termToValue (CompoundTerm (Types.Unqualified n) []) = pure (VAtom n)
termToValue (CompoundTerm name ts) = VTerm (vmName name).unName <$> traverse termToValue ts

-- | Execute a single desugared body goal in the query context.
executeBodyGoal :: D.BodyGoal -> QueryM ()
executeBodyGoal D.BodyTrue = pure ()
executeBodyGoal (D.BodyUnify l r) = do
  v1 <- exprToValue l
  v2 <- exprToValue r
  lift (queryUnify v1 v2)
executeBodyGoal (D.BodyHostStmt f args) = do
  argVals <- traverse evalNestedExpr args
  env <- lift ask
  result <- lift (hostCall (Map.lookup (Name f) env.hostCalls) f argVals)
  lift $
    emitTrace $ do
      argTs <- snapshotValues argVals
      resT <- snapshotValue result
      pure (TECallHost f argTs resT)
executeBodyGoal (D.BodyIs v expr) = do
  -- Mirror 'evalValExpr (EvalDeep (Var _))' in the compiled interpreter:
  -- when the RHS is syntactically a variable, walk the dereferenced
  -- value so a bound compound with an evaluable functor gets evaluated
  -- (@X = 1 + 1, R is X@ ⇒ @R = 2@). For all other RHS shapes the
  -- result of the outer typed operation is already final.
  raw <- evalNestedExpr expr
  result <- case expr of
    R.VarExpr _ -> lift (deepEvalValue raw)
    _ -> pure raw
  varMap <- get
  case Map.lookup v varMap of
    Just existing -> lift (queryUnify existing result)
    Nothing -> modify (Map.insert v result)
executeBodyGoal (D.BodyTell qn args) = do
  argVals <- traverse evalNestedExpr args
  lift (tellConstraint (Types.qualifiedToName qn) argVals)
executeBodyGoal (D.BodyCall qn args) = do
  argVals <- traverse evalNestedExpr args
  let funcName = Types.qualifiedToName qn
  _ <- lift (callProc (funcProcName funcName (length argVals)) (map CVal argVals))
  pure ()
executeBodyGoal (D.BodyApply f args) = do
  fAndArgVals <- traverse evalNestedExpr (f : args)
  let n = length args
      dispatchName = Name ("call_" <> T.pack (show n))
  _ <- lift (callProc dispatchName (map CVal fAndArgVals))
  pure ()

-- | Raise a runtime error describing a failed unification.
raiseUnifyFailure :: Value -> Value -> Chr ()
raiseUnifyFailure v1 v2 = do
  t1 <- valueToTerm Map.empty v1
  t2 <- valueToTerm Map.empty v2
  runtimeErrorS $
    "unification failure: cannot unify "
      ++ prettyTerm t1
      ++ " with "
      ++ prettyTerm t2

-- | Call a host function, failing with a coded runtime error if it is not
-- registered or if it throws.
--
-- Mirrors 'YCHR.Internal.Runtime.Interpreter.invokeHostCall': an
-- arbitrary exception out of a host function (an 'IOException' from a
-- user 'YCHR.Convert.hostFnValues' handler, a parse failure inside a
-- built-in) is re-raised through 'runtimeErrorS' so it reaches the caller
-- as 'Error''s 'RuntimeError' with a call stack, rather than escaping raw.
-- Async and already-coded exceptions keep their identity.
--
-- Unlike 'invokeHostCall' there is no @ControlFlow@ case: that exception
-- is interpreter-internal, is caught by 'callProc' before control
-- returns, and is not exported — so no 'HostCallFn' reachable from here,
-- built-in or user-supplied, can raise it.
hostCall :: Maybe HostCallFn -> Text -> [Value] -> Chr Value
hostCall (Just (HostCallFn f)) name args = do
  env <- ask
  result <- liftIO (try @SomeException (runReaderT (f args) env))
  case result of
    Right v -> pure v
    Left exc
      | Just (ae :: SomeAsyncException) <- fromException exc ->
          liftIO (throwIO ae)
      | Just (rte :: RuntimeErrorThrown) <- fromException exc ->
          liftIO (throwIO rte)
      | otherwise ->
          runtimeErrorS $
            "host call " ++ T.unpack name ++ ": " ++ displayException exc
hostCall Nothing name _ =
  runtimeErrorS $ "Unknown host function: " ++ T.unpack name

-- | Drain the reactivation queue, dispatching each constraint.
-- Mirrors the VM's 'DrainReactivationQueue' statement, including
-- the per-suspension 'TEReactivate' event for the tracer.
drainReactivation :: Chr ()
drainReactivation =
  drainQueue $ \sid -> do
    alive <- aliveConstraint sid
    when alive $ do
      emitTrace $ do
        (ct, vs) <- suspensionView sid
        ctName <- constraintTypeLabel ct
        ts <- snapshotValues vs
        pure (TEReactivate sid ctName ts)
      void $ callProc (Name "reactivate_dispatch") [CId sid]

-- | Run a query-side unification, mirroring the interpreter's
-- 'evalBoolExpr (BUnify ...)' branch: snapshot the operand terms
-- /before/ the unify mutates anything (only when tracing is on),
-- run the unify, enqueue observers, emit a 'TEUnify' event on
-- success with the number of observers reactivated, raise on
-- failure, then drain the reactivation queue. The trace event is
-- skipped on failure for consistency with the interpreter path.
queryUnify :: Value -> Value -> Chr ()
queryUnify v1 v2 = do
  env <- ask
  mh <- liftIO (readIORef env.traceHandler)
  case mh of
    Nothing -> do
      (ok, observers) <- unify v1 v2
      enqueue observers
      unless ok (raiseUnifyFailure v1 v2)
      drainReactivation
    Just _ -> do
      t1 <- snapshotValue v1
      t2 <- snapshotValue v2
      (ok, observers) <- unify v1 v2
      enqueue observers
      if ok
        then do
          emitTrace (pure (TEUnify t1 t2 (length observers)))
          drainReactivation
        else raiseUnifyFailure v1 v2

-- | Build a runtime 'Value' from a desugared 'D.Expr' without
-- evaluating embedded function calls. Mirrors 'termToValue' on the
-- typed side by round-tripping through the surface 'Term' shape via
-- 'R.exprToTerm', so query-time value construction stays bit-for-bit
-- compatible with the pre-refactor behaviour.
exprToValue :: D.Expr -> QueryM Value
exprToValue = termToValue . R.exprToTerm

-- | Evaluate an expression in the query context (used for @is@ RHS
-- and guard expressions). 'CallExpr', 'ApplyExpr', and 'HostExpr'
-- evaluate their arguments and invoke the appropriate procedure;
-- 'CtorExpr' (and the @quote\/1@ quoting form) build values
-- structurally without re-evaluating their children.
evalNestedExpr :: D.Expr -> QueryM Value
evalNestedExpr (R.IntExpr n) = pure (VInt n)
evalNestedExpr (R.FloatExpr n) = pure (VFloat n)
evalNestedExpr (R.TextExpr s) = pure (VText s)
evalNestedExpr R.WildcardExpr = pure VWildcard
evalNestedExpr (R.VarExpr v) = do
  varMap <- get
  case Map.lookup v varMap of
    Just val -> lift (deref val)
    Nothing -> do
      fresh <- lift newVar
      modify (Map.insert v fresh)
      pure fresh
evalNestedExpr (R.CallExpr qn args) = do
  argVals <- traverse evalNestedExpr args
  let funcName = Types.qualifiedToName qn
  lift (callProc (funcProcName funcName (length argVals)) (map CVal argVals))
evalNestedExpr (R.ApplyExpr f args) = do
  fAndArgVals <- traverse evalNestedExpr (f : args)
  let n = length args
      dispatchName = Name ("call_" <> T.pack (show n))
  lift (callProc dispatchName (map CVal fAndArgVals))
evalNestedExpr (R.HostExpr f args) = do
  argVals <- traverse evalNestedExpr args
  env <- lift ask
  lift (hostCall (Map.lookup (Name f) env.hostCalls) f argVals)
-- @quote(X)@ short-circuit: build the inner value as data, no
-- nested-call evaluation. Mirrors the legacy 'termToValue arg' path.
evalNestedExpr (R.CtorExpr (Types.Unqualified "quote") [arg]) = exprToValue arg
-- Native-bool fast path. Mirrors 'Compile.compileExpr' for
-- @prelude:true@/@prelude:false@: queries must produce 'VBool' just
-- like compiled rules, so a REPL @is@ RHS or tell-side argument
-- agrees with comparison results (which return 'VBool' directly).
evalNestedExpr (R.CtorExpr (Types.Qualified "prelude" "true") []) = pure (VBool True)
evalNestedExpr (R.CtorExpr (Types.Qualified "prelude" "false") []) = pure (VBool False)
-- 0-arity ctors collapse to atoms at the runtime layer.
evalNestedExpr (R.CtorExpr name@(Types.Qualified _ _) []) =
  pure (VAtom (vmName name).unName)
evalNestedExpr (R.CtorExpr (Types.Unqualified n) []) = pure (VAtom n)
evalNestedExpr (R.CtorExpr name args) =
  -- Recurse with 'evalNestedExpr' (not 'exprToValue'): a 'CtorExpr'
  -- can contain nested 'CallExpr' / 'HostExpr' children that must
  -- evaluate before the surrounding compound is built. Mirrors the
  -- compiled path in 'Compile.compileExpr' for 'CtorExpr'.
  VTerm (vmName name).unName <$> traverse evalNestedExpr args
evalNestedExpr e@(R.FunRefExpr _ _) = exprToValue e
evalNestedExpr (R.LambdaExpr _ _) =
  error "Run.evalNestedExpr: LambdaExpr survived lambda lifting"

-- | Compile lifted query lambdas into VM procedures. Discards the
-- error channel: by the time this runs, 'prepareQuery' has already
-- lifted these lambdas from a desugared program that compiled
-- cleanly and has type-checked them, so any error here would
-- indicate a compiler bug rather than a user problem.
compileQueryLambdas :: [D.Function] -> [Procedure]
compileQueryLambdas lambdas =
  let (procs, _errs) = runWriter $ traverse compileFunctionDef lambdas
   in procs