packages feed

nova-nix-0.3.0.0: src/Nix/Eval/Types.hs

{-# LANGUAGE GeneralizedNewtypeDeriving #-}

-- | Shared types for the Nix evaluator.
--
-- Extracted into its own module so that 'Nix.Eval.Operator',
-- 'Nix.Eval.StringInterp', and 'Nix.Builtins' can all reference
-- 'NixValue', 'Thunk', and 'Env' without creating import cycles.
module Nix.Eval.Types
  ( -- * Values
    NixValue (..),
    CompiledRegex (..),
    Thunk (..),
    readThunkValue,

    -- * Attribute sets (C-backed sorted arrays)
    AttrSet (..),
    CAttrSet,
    attrSetLookup,
    attrSetKeys,
    attrSetToMap,
    attrSetFromMap,
    attrSetMember,
    attrSetNull,
    attrSetSize,
    attrSetElems,
    attrSetToAscList,
    attrSetMapWithKey,
    attrSetRemoveKeys,
    attrSetUnionWith,
    buildCAttrSetKeys,
    fillCAttrSetValues,

    -- * Lists (C-backed)
    CList (..),
    emptyCList,
    clistFromThunks,
    clistThunks,
    clistLen,

    -- * String context
    StringContextElement (..),
    StringContext (..),
    emptyContext,
    mkStr,
    marshalStringContext,
    unmarshalStringContext,

    -- * Environment
    Env (..),
    NnEnv,
    emptyEnv,
    envLookup,
    envLookupResolved,
    envFromSlots,
    pushWithScope,
    lookupWithScopes,
    withScopesForCapture,
    envWithScopesRaw,
    newCEnv,
    newMinimalEnv,

    -- * Eval-time formals (re-exported from EvalFormals)
    EvalFormals (..),
    EvalFormal (..),

    -- * Thunk operations
    mkThunk,
    mkSyntheticThunk,
    cheapThunk,
    cheapThunkBc,
    mkThunkBc,
    evaluated,
    thunkSameRef,
    thunkToCPtr,
    buildCSlots,
    allocCSlots,
    fillCSlots,

    -- * Lambda marshalling
    marshalLambda,
    unmarshalLambdaValue,

    -- * Display
    typeName,

    -- * Evaluation monad
    MonadEval (..),
    PureEval (..),
  )
where

import Control.Monad (forM_)
import Data.Bits (shiftL, (.&.), (.|.))
import Data.ByteString (ByteString)
import Data.Int (Int64)
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
import Data.Set (Set)
import qualified Data.Set as Set
import Data.Text (Text)
import Data.Word (Word16, Word32, Word64, Word8)
import Foreign.Ptr (Ptr, castPtr, nullPtr, ptrToWordPtr)
import Foreign.StablePtr (castPtrToStablePtr, castStablePtrToPtr, deRefStablePtr, newStablePtr)
import Foreign.Storable (peekElemOff, pokeElemOff)
import GHC.Float (castWord64ToDouble)
import Nix.Derivation (Derivation)
import Nix.Eval.CAttrSet (CAttrSet, cattrsetFreeze, cattrsetGetKey, cattrsetGetValue, cattrsetIndex, cattrsetInsert, cattrsetKeys, cattrsetLookup, cattrsetNew, cattrsetRemoveKeys, cattrsetSetValue, cattrsetSize)
import Nix.Eval.CBytecode (cbcArg1, cbcArg2, cbcOpcode, cbcShortArg)
import Nix.Eval.CCtxStr (CCtxStrPtr, cctxstrCtxCount, cctxstrElemHash, cctxstrElemName, cctxstrElemOutput, cctxstrElemTag, cctxstrNew, cctxstrSetAllOutputs, cctxstrSetDrvOutput, cctxstrSetPlain, cctxstrText)
import Nix.Eval.CEnv (NnEnv, cenvAllocSlots, cenvAllocWithScopes, cenvEmpty, cenvFromSlots, cenvLazyScope, cenvLookupResolved, cenvNew, cenvNewMinimal, cenvParent, cenvPushWith, cenvRootScope, cenvSlotCount, cenvWithCount, cenvWithScopes)
import Nix.Eval.CLambda (clambdaAllowExtra, clambdaBody, clambdaEntryDefault, clambdaEntryHasDefault, clambdaEntryName, clambdaEnv, clambdaFormalCount, clambdaFormalsType, clambdaNameSym, clambdaNew, clambdaSetEntry)
import Nix.Eval.CList (CList (..), clistFromThunks, clistLen, clistThunks, emptyCList)
import Nix.Eval.CThunk (CThunkPtr, cthunkGetAttrs, cthunkGetBcIdx, cthunkGetBool, cthunkGetCtxStr, cthunkGetFloat, cthunkGetInt, cthunkGetList, cthunkGetPath, cthunkGetStr, cthunkNewBc, cthunkNewComputed, cthunkNewComputedAttrs, cthunkNewComputedBool, cthunkNewComputedCtxStr, cthunkNewComputedFloat, cthunkNewComputedInt, cthunkNewComputedLambda, cthunkNewComputedList, cthunkNewComputedNull, cthunkNewComputedPath, cthunkNewComputedStr, cthunkPayload, cthunkState, cthunkValueTag)
import Nix.Eval.Compile (compileExpr, compileFormalsToEval)
import Nix.Eval.EvalFormals (EvalFormal (..), EvalFormals (..))
import Nix.Eval.Symbol (Symbol (..), symbolIntern, symbolText)
import Nix.Expr.Types (CaptureInfo (..), Expr (..), NixAtom (..))
import Nix.Store.Path (StorePath (..))
import System.IO.Unsafe (unsafePerformIO)
import qualified Text.Regex.TDFA as RE

-- ---------------------------------------------------------------------------
-- Compiled regex
-- ---------------------------------------------------------------------------

-- | Compiled regex with Eq/Show based on source pattern text.
-- Carries the pre-compiled 'RE.Regex' alongside the original pattern
-- so that partial application of @builtins.match@ / @builtins.split@
-- avoids recompiling the same pattern on every invocation.
data CompiledRegex = CompiledRegex !Text RE.Regex

instance Eq CompiledRegex where
  CompiledRegex a _ == CompiledRegex b _ = a == b

instance Show CompiledRegex where
  show (CompiledRegex pat _) = "CompiledRegex " <> show pat

-- ---------------------------------------------------------------------------
-- String context
-- ---------------------------------------------------------------------------

-- | A single element of string context, tracking where a string
-- references store paths.
data StringContextElement
  = -- | Plain store path reference (inputSrcs).
    SCPlain !StorePath
  | -- | Derivation output reference (inputDrvs): .drv path + output name.
    SCDrvOutput !StorePath !Text
  | -- | All outputs of a derivation (for drvPath itself).
    SCAllOutputs !StorePath
  deriving (Eq, Ord, Show)

-- | Context carried by Nix strings, tracking store path dependencies.
newtype StringContext = StringContext {unStringContext :: Set StringContextElement}
  deriving (Eq, Ord, Show, Semigroup, Monoid)

-- | Empty string context (alias for 'mempty').
emptyContext :: StringContext
emptyContext = mempty

-- | Smart constructor for context-free strings.
mkStr :: Text -> NixValue
mkStr t = VStr t emptyContext

-- ---------------------------------------------------------------------------
-- Thunks
-- ---------------------------------------------------------------------------

-- | A thunk: a C arena-allocated memoization cell.
--
-- Each thunk points to an @nn_thunk_t@ in the C arena.
-- On first force, the cell transitions PENDING -> BLACKHOLE -> COMPUTED,
-- which detects infinite recursion (BLACKHOLE) and drops the Expr/Env
-- references — matching real Nix which mutates thunks in-place.
--
-- The C thunk is allocated via 'unsafePerformIO' in 'mkThunk' (same
-- pattern as the former IORef-based approach) so that knot-tying works
-- unchanged.  Arena pointers remain valid until 'cthunkDestroy'.
newtype Thunk = Thunk {unThunk :: CThunkPtr}

instance Show Thunk where
  show (Thunk ptr) =
    case unsafePerformIO (cthunkState ptr) of
      1 {- COMPUTED -} -> case readThunkValue (Thunk ptr) of
        Just val -> "Thunk (" ++ show val ++ ")"
        Nothing -> "Thunk <computed?>"
      0 -> "Thunk <pending>"
      2 -> "Thunk <blackhole>"
      _ -> "Thunk <unknown>"

-- | Equality: pointer identity fast path, then value comparison for
-- COMPUTED thunks.  Only used in tests on non-recursive structures.
instance Eq Thunk where
  (Thunk p1) == (Thunk p2)
    | p1 == p2 = True
    | otherwise = case (readThunkValue (Thunk p1), readThunkValue (Thunk p2)) of
        (Just v1, Just v2) -> v1 == v2
        _ -> False

-- | Read a COMPUTED thunk's value without forcing.
-- Returns 'Nothing' for PENDING or BLACKHOLE thunks.
-- Uses 'unsafePerformIO' — safe because C reads are idempotent.
readThunkValue :: Thunk -> Maybe NixValue
readThunkValue (Thunk ptr) =
  unsafePerformIO $ do
    state <- cthunkState ptr
    if state /= 1
      then pure Nothing
      else do
        tag <- cthunkValueTag ptr
        fmap Just $ case tag of
          0 {- INT -} -> VInt <$> cthunkGetInt ptr
          1 {- FLOAT -} -> VFloat <$> cthunkGetFloat ptr
          2 {- BOOL -} -> (\b -> VBool (b /= 0)) <$> cthunkGetBool ptr
          3 {- NULL -} -> pure VNull
          4 {- STR -} -> (\sym -> VStr (symbolText (Symbol sym)) emptyContext) <$> cthunkGetStr ptr
          5 {- PATH -} -> VPath . symbolText . Symbol <$> cthunkGetPath ptr
          6 {- LIST -} -> do
            listPtr <- cthunkGetList ptr
            pure (VList (CList (castPtr listPtr)))
          7 {- ATTRS -} -> VAttrs . AttrSet . castPtr <$> cthunkGetAttrs ptr
          8 {- CTXSTR -} -> do
            csptr <- cthunkGetCtxStr ptr
            uncurry VStr <$> unmarshalStringContext (castPtr csptr)
          9 {- LAMBDA -} -> do
            lamRaw <- cthunkPayload ptr
            unmarshalLambdaValue (castPtr lamRaw)
          _ {- PTR -} -> do
            payload <- cthunkPayload ptr
            deRefStablePtr (castPtrToStablePtr payload)

-- | A Nix value — the result of evaluating an expression.
data NixValue
  = -- | 64-bit signed integer (matching Nix semantics).
    VInt !Int64
  | -- | Floating-point.
    VFloat !Double
  | -- | Boolean.
    VBool !Bool
  | -- | The null value.
    VNull
  | -- | String with dependency context.
    VStr !Text !StringContext
  | -- | Path.
    VPath !Text
  | -- | List of thunks (lazy elements), backed by C array.
    VList !CList
  | -- | Attribute set: unified lazy/eager representation.
    VAttrs !AttrSet
  | -- | Lambda closure: captures environment, formals, body bytecode index.
    VLambda !Env !EvalFormals !Word32
  | -- | A realized derivation (build recipe).
    VDerivation !Derivation
  | -- | Built-in function, dispatched by name.
    -- Accumulated args support curried partial application.
    VBuiltin !Text ![NixValue]
  | -- | Pre-compiled regex carried in a partially-applied builtin.
    -- Internal only — never exposed to Nix code directly.
    VCompiledRegex !CompiledRegex
  deriving (Eq, Show)

-- ---------------------------------------------------------------------------
-- Attribute sets (C-backed sorted arrays)
-- ---------------------------------------------------------------------------

-- | Attribute set backed by a C-allocated sorted key-value array.
--
-- All keys are interned symbols; values are CThunkPtrs in a parallel
-- array.  O(log n) binary search on contiguous memory for lookup.
-- Replaces the former EagerAttrs\/LazyAttrs\/MappedAttrs\/CAttrs ADT
-- with a single C-backed representation — all attr set data lives off
-- the GHC heap, dramatically reducing GC pressure for large evaluations.
newtype AttrSet = AttrSet {unAttrSet :: CAttrSet}

instance Eq AttrSet where
  a == b = attrSetToMap a == attrSetToMap b

instance Show AttrSet where
  show (AttrSet cset) =
    let n = unsafePerformIO (cattrsetSize cset)
     in "<attrset " ++ show n ++ " entries>"

-- | Look up a single key via symbol interning + C binary search.
-- Uses 'unsafePerformIO' — safe because symbolIntern and cattrsetLookup
-- are idempotent (same key always yields same symbol and same result).
attrSetLookup :: Text -> AttrSet -> Maybe Thunk
attrSetLookup key (AttrSet cset) =
  unsafePerformIO $ do
    sym <- symbolIntern key
    mptr <- cattrsetLookup cset sym
    pure (fmap Thunk mptr)

-- | All keys in sorted order (symbol text from C).
attrSetKeys :: AttrSet -> [Text]
attrSetKeys (AttrSet cset) =
  map symbolText (unsafePerformIO (cattrsetKeys cset))

-- | Check key membership without materializing thunks.
attrSetMember :: Text -> AttrSet -> Bool
attrSetMember key (AttrSet cset) =
  unsafePerformIO $ do
    sym <- symbolIntern key
    midx <- cattrsetIndex cset sym
    pure (case midx of Nothing -> False; Just _ -> True)

-- | Check if the attribute set is empty.
attrSetNull :: AttrSet -> Bool
attrSetNull (AttrSet cset) =
  unsafePerformIO (cattrsetSize cset) == 0

-- | Number of attributes.
attrSetSize :: AttrSet -> Int
attrSetSize (AttrSet cset) =
  fromIntegral (unsafePerformIO (cattrsetSize cset))

-- | Full materialization: build a 'Map Text Thunk' from all entries.
-- Iterates the C array and builds a Haskell Map.  Expensive on large
-- sets — avoid on the hot path.
attrSetToMap :: AttrSet -> Map Text Thunk
attrSetToMap (AttrSet cset) =
  unsafePerformIO $ do
    n <- cattrsetSize cset
    if n == 0
      then pure Map.empty
      else do
        pairs <- mapM materializeEntry [0 .. n - 1]
        pure (Map.fromList pairs)
  where
    materializeEntry i = do
      sym <- cattrsetGetKey cset i
      ptr <- cattrsetGetValue cset i
      pure (symbolText sym, Thunk ptr)

-- | All thunk values (materialized).
attrSetElems :: AttrSet -> [Thunk]
attrSetElems = Map.elems . attrSetToMap

-- | Sorted key-value pairs (materialized).
attrSetToAscList :: AttrSet -> [(Text, Thunk)]
attrSetToAscList = Map.toAscList . attrSetToMap

-- | Map a function over all key-value pairs, building a new CAttrSet.
-- Materializes the source set, applies @f@ to each entry, and rebuilds.
attrSetMapWithKey :: (Text -> Thunk -> Thunk) -> AttrSet -> AttrSet
attrSetMapWithKey f attrs = attrSetFromMap (Map.mapWithKey f (attrSetToMap attrs))

-- | Remove a list of keys, returning a new CAttrSet.
-- Uses the C-side @nn_attrset_remove_keys@ for O(n) removal.
{-# NOINLINE attrSetRemoveKeys #-}
attrSetRemoveKeys :: [Text] -> AttrSet -> AttrSet
attrSetRemoveKeys keys (AttrSet cset) = unsafePerformIO $ do
  syms <- mapM symbolIntern keys
  newSet <- cattrsetRemoveKeys cset syms
  pure (AttrSet newSet)

-- | Union two attribute sets with a combining function.
-- Materializes both to Maps, applies the combining function, rebuilds.
attrSetUnionWith :: (Thunk -> Thunk -> Thunk) -> AttrSet -> AttrSet -> AttrSet
attrSetUnionWith f a b = attrSetFromMap (Map.unionWith f (attrSetToMap a) (attrSetToMap b))

-- | Build a CAttrSet from a Haskell 'Map'.  Interns all keys as symbols,
-- inserts key-value pairs, freezes (sort + dedup).  The canonical entry
-- point for all attribute set construction.
--
-- Uses 'unsafePerformIO' with @NOINLINE@ — safe because C allocation
-- is idempotent and the resulting CAttrSet is referentially transparent.
{-# NOINLINE attrSetFromMap #-}
attrSetFromMap :: Map Text Thunk -> AttrSet
attrSetFromMap m = m `seq` unsafePerformIO $ do
  let pairs = Map.toList m
      n = fromIntegral (length pairs)
  cset <- cattrsetNew n
  forM_ pairs $ \(key, Thunk ptr) -> do
    sym <- symbolIntern key
    cattrsetInsert cset sym ptr
  cattrsetFreeze cset
  pure (AttrSet cset)

-- | Allocate a CAttrSet skeleton with keys only (NULL values).
-- Used for two-phase construction in @rec {}@ and @let@ (knot-tying):
-- keys are known before thunks, so the CAttrSet is built first, then
-- values are filled via 'fillCAttrSetValues'.
{-# NOINLINE buildCAttrSetKeys #-}
buildCAttrSetKeys :: [Text] -> CAttrSet
buildCAttrSetKeys keys = unsafePerformIO $ do
  let n = fromIntegral (length keys)
  cset <- cattrsetNew n
  forM_ keys $ \key -> do
    sym <- symbolIntern key
    cattrsetInsert cset sym nullPtr
  cattrsetFreeze cset
  pure cset

-- | Fill values into a pre-allocated CAttrSet (two-phase construction).
-- Looks up each key's index and writes the CThunkPtr at that position.
-- Keys not found in the CAttrSet are silently ignored (should not happen
-- in correct knot-tying code).
{-# NOINLINE fillCAttrSetValues #-}
fillCAttrSetValues :: CAttrSet -> Map Text Thunk -> ()
fillCAttrSetValues cset thunkMap = unsafePerformIO $
  forM_ (Map.toList thunkMap) $ \(key, Thunk ptr) -> do
    sym <- symbolIntern key
    midx <- cattrsetIndex cset sym
    case midx of
      Just idx -> cattrsetSetValue cset idx ptr
      Nothing -> pure ()

-- | Evaluation environment — C-backed scope chain.
--
-- Arena-allocated @nn_env_t@ struct (40 bytes, zero GC overhead).
-- All env data (slots, lazy scope, parent, with-scopes) lives in C.
-- Haskell holds only the pointer.  Variable lookup has two paths:
--
-- * 'envLookupResolved': single C call for 'EResolvedVar'
-- * 'envLookup': name-based walk for 'EVar' (lazy scope + with-scopes)
newtype Env = Env (Ptr NnEnv)

-- | Pointer equality: two envs are equal iff they are the same C struct.
instance Eq Env where
  Env p1 == Env p2 = p1 == p2

-- | Compact show via C accessors.
instance Show Env where
  show (Env envPtr) =
    let sc = unsafePerformIO (cenvSlotCount envPtr)
        ls = unsafePerformIO (cenvLazyScope envPtr)
        par = unsafePerformIO (cenvParent envPtr)
        wc = unsafePerformIO (cenvWithCount envPtr)
     in "Env{"
          ++ show sc
          ++ " slots"
          ++ (if ls /= nullPtr then ", lazyScope" else "")
          ++ (if par /= nullPtr then ", parent" else "")
          ++ ", "
          ++ show wc
          ++ " withs}"

-- | Empty environment (no variables in scope).
-- Points to a static global C struct — valid until 'arenaDestroy'.
{-# NOINLINE emptyEnv #-}
emptyEnv :: Env
emptyEnv = Env (unsafePerformIO cenvEmpty)

-- | General C-backed env constructor.
-- Takes Haskell-level types; converts Maybe to nullPtr internally.
{-# NOINLINE newCEnv #-}
newCEnv :: Ptr CThunkPtr -> Int -> Maybe AttrSet -> Maybe Env -> Ptr (Ptr ()) -> Word16 -> Env
newCEnv slots slotCount lazyScope parent withs withCount =
  Env
    ( unsafePerformIO
        ( cenvNew
            slots
            (fromIntegral slotCount)
            (case lazyScope of Nothing -> nullPtr; Just (AttrSet cset) -> castPtr cset)
            (case parent of Nothing -> nullPtr; Just (Env p) -> p)
            withs
            withCount
        )
    )

-- | Minimal env: slots only, no parent, no with-scopes, no lazy scope.
{-# NOINLINE newMinimalEnv #-}
newMinimalEnv :: Ptr CThunkPtr -> Int -> Env
newMinimalEnv slots n =
  Env (unsafePerformIO (cenvNewMinimal slots (fromIntegral n)))

-- | Look up a resolved variable by level and index.  Single C call:
-- O(level) parent hops in C, then O(1) array read.
envLookupResolved :: Int -> Int -> Env -> Thunk
envLookupResolved level idx (Env envPtr) =
  Thunk (unsafePerformIO (cenvLookupResolved envPtr level idx))

-- | Name-based variable lookup: walk the parent chain checking
-- lazy scopes; fall back to with-scopes (from the starting env).
--
-- Positional slots are NOT searched here — used only for
-- 'EVar' lookups (let\/rec bindings, builtins, with-scopes).
envLookup :: Text -> Env -> Maybe Thunk
envLookup name (Env envPtr) = lexicalLookup envPtr
  where
    -- With-scopes from the STARTING env (C array)
    startWiths = unsafePerformIO (cenvWithScopes envPtr)
    startWithCount = unsafePerformIO (cenvWithCount envPtr)
    lexicalLookup ep =
      let ls = unsafePerformIO (cenvLazyScope ep)
          scopeResult =
            if ls /= nullPtr
              then attrSetLookup name (AttrSet (castPtr ls))
              else Nothing
       in case scopeResult of
            Just val -> Just val
            Nothing ->
              let par = unsafePerformIO (cenvParent ep)
               in if par /= nullPtr
                    then lexicalLookup par
                    else lookupWithScopesC name startWiths startWithCount

-- | Walk with-scopes (C array) innermost to outermost.
-- Skips tagged lazy entries (bit 0 set) — those are thunk pointers
-- that can only be resolved by 'evalWithVarScopes' which has monadic
-- 'force'.  This is a safety guard: currently unreachable because
-- 'resolveVars' ensures EVar behind a with-scope always becomes
-- EWithVar, but protects against future changes.
lookupWithScopesC :: Text -> Ptr (Ptr ()) -> Word16 -> Maybe Thunk
lookupWithScopesC _ _ 0 = Nothing
lookupWithScopesC name withArr count = unsafePerformIO $ go 0
  where
    go i
      | i >= fromIntegral count = pure Nothing
      | otherwise = do
          scopePtr <- peekElemOff withArr i
          if ptrToWordPtr scopePtr .&. 1 /= 0
            then go (i + 1) -- skip tagged lazy with-scope
            else case attrSetLookup name (AttrSet (castPtr scopePtr)) of
              Just val -> pure (Just val)
              Nothing -> go (i + 1)

-- | Walk with-scopes as a Haskell list (backward-compatible signature).
lookupWithScopes :: Text -> [AttrSet] -> Maybe Thunk
lookupWithScopes _ [] = Nothing
lookupWithScopes name (scope : rest) =
  case attrSetLookup name scope of
    Just val -> Just val
    Nothing -> lookupWithScopes name rest

-- | Create a child env with positional slots (for lambda formals).
-- Inherits with-scopes from the parent.  Arena-allocated.
{-# NOINLINE envFromSlots #-}
envFromSlots :: Ptr CThunkPtr -> Int -> Env -> Env
envFromSlots slotsPtr slotCount (Env parentPtr) =
  Env (unsafePerformIO (cenvFromSlots slotsPtr (fromIntegral slotCount) parentPtr))

-- | Push a with-scope onto the scope chain (innermost position).
-- Allocates a new C env struct with extended with-scopes array.
{-# NOINLINE pushWithScope #-}
pushWithScope :: AttrSet -> Env -> Env
pushWithScope (AttrSet cset) (Env envPtr) =
  Env (unsafePerformIO (cenvPushWith envPtr (castPtr cset)))

-- | Read with-scopes array pointer and count from a C env.
{-# NOINLINE envWithScopesRaw #-}
envWithScopesRaw :: Env -> (Ptr (Ptr ()), Word16)
envWithScopesRaw (Env envPtr) = unsafePerformIO $ do
  withs <- cenvWithScopes envPtr
  count <- cenvWithCount envPtr
  pure (withs, count)

-- | Build with-scopes for a trimmed env that needs with-scope access
-- ('CapturesWithScopes').  Appends the root scope (builtins) as the
-- outermost entry so 'EWithVar' can fall back to builtins without
-- retaining the parent chain.  Returns C array + count.
{-# NOINLINE withScopesForCapture #-}
withScopesForCapture :: Env -> (Ptr (Ptr ()), Word16)
withScopesForCapture (Env envPtr) = unsafePerformIO $ do
  rootPtr <- cenvRootScope envPtr
  existingWiths <- cenvWithScopes envPtr
  existingCount <- cenvWithCount envPtr
  if rootPtr == nullPtr
    then pure (existingWiths, existingCount)
    else do
      let newCount = existingCount + 1
      arr <- cenvAllocWithScopes newCount
      -- Copy existing with-scopes
      forM_ [0 .. fromIntegral existingCount - 1] $ \i -> do
        val <- peekElemOff existingWiths i
        pokeElemOff arr i val
      -- Append root scope at end (outermost)
      pokeElemOff arr (fromIntegral existingCount) rootPtr
      pure (arr, newCount)

-- | Create an unevaluated thunk with a fresh C arena-allocated cell.
--
-- Compiles the Expr to bytecode and stores (bc_idx, env_ptr) in the
-- C thunk — no StablePtr, zero GHC heap pressure for pending thunks.
-- The cell is allocated via 'unsafePerformIO' — safe because C
-- allocation is a pure side effect, and the @NOINLINE@ + @seq@
-- pattern prevents GHC from floating the allocation to a shared
-- top-level CAF.
mkThunk :: Env -> Expr -> Thunk
mkThunk env thunkExpr =
  Thunk (newBcThunkPtr thunkExpr env)

-- | Like 'mkThunk' but for synthetic thunks that reuse the same 'Expr'
-- (e.g. @EApp (EResolvedVar 0 0) (EResolvedVar 0 1)@ in 'deferApply').
-- Uses the env pointer for cell uniqueness instead of the expression,
-- since GHC's full-laziness transform would otherwise float the shared
-- expr to a CAF and all thunks would get the same cell.
--
-- Must only be called with freshly-constructed envs (not knot-tied
-- recursive envs), since it forces the env pointer to WHNF.
mkSyntheticThunk :: Env -> Expr -> Thunk
mkSyntheticThunk env@(Env envPtr) thunkExpr =
  Thunk (newSyntheticBcThunkPtr envPtr thunkExpr env)

-- | Like 'mkThunk' but avoids C arena allocation for trivial expressions.
-- Resolved variables reuse the existing thunk from the env (no wrapper).
-- Literals use inline C scalars (no StablePtr).
-- Lambdas compile formals+body to bytecode and produce VLambda directly.
-- Everything else falls back to 'mkThunk'.
cheapThunk :: Env -> Expr -> Thunk
cheapThunk env (EResolvedVar level idx) = envLookupResolved level idx env
cheapThunk _ (ELit (NixInt n)) = Thunk (newComputedIntPtr n)
cheapThunk _ (ELit (NixFloat n)) = Thunk (newComputedFloatPtr n)
cheapThunk _ (ELit (NixBool b)) = Thunk (newComputedBoolPtr (if b then 1 else 0))
cheapThunk _ (ELit NixNull) = Thunk newComputedNullPtr
cheapThunk env (ELambda formals body NoCaptureInfo) =
  let bodyBcIdx = unsafePerformIO (compileExpr body)
      evalFormals = unsafePerformIO (compileFormalsToEval formals)
   in evaluated (VLambda env evalFormals bodyBcIdx)
cheapThunk env (ELambda formals body (Captures captureList)) =
  let (slotsPtr, slotCount) = buildCSlots [envLookupResolved lvl idx env | (lvl, idx) <- captureList]
      trimmedEnv = newMinimalEnv slotsPtr slotCount
      bodyBcIdx = unsafePerformIO (compileExpr body)
      evalFormals = unsafePerformIO (compileFormalsToEval formals)
   in evaluated (VLambda trimmedEnv evalFormals bodyBcIdx)
cheapThunk env (ELambda formals body (CapturesWithScopes captureList)) =
  let (slotsPtr, slotCount) = buildCSlots [envLookupResolved lvl idx env | (lvl, idx) <- captureList]
      (withArr, withCount) = withScopesForCapture env
      trimmedEnv = newCEnv slotsPtr slotCount Nothing Nothing withArr withCount
      bodyBcIdx = unsafePerformIO (compileExpr body)
      evalFormals = unsafePerformIO (compileFormalsToEval formals)
   in evaluated (VLambda trimmedEnv evalFormals bodyBcIdx)
cheapThunk env expr = mkThunk env expr

-- | Create a pending thunk from a bytecode index (no compilation needed).
-- Used inside 'evalBytecode' where the bc_idx is already known.
-- The Env is captured LAZILY (for knot-tying in rec attrs / let / matchFormalSet).
mkThunkBc :: Env -> Word32 -> Thunk
mkThunkBc env bcIdx =
  Thunk (newBcThunkPtrLazy bcIdx env)

-- | Like 'cheapThunk' but for bytecode indices (used inside evalBytecode).
-- Short-circuits for resolved vars and literals without creating a
-- pending thunk.  Everything else falls back to 'mkThunkBc'.
cheapThunkBc :: Env -> Word32 -> Thunk
cheapThunkBc env bcIdx =
  let opcode = unsafePerformIO (cbcOpcode bcIdx)
   in case opcode of
        10 {- RESOLVED_VAR -} ->
          let level = fromIntegral (unsafePerformIO (cbcArg1 bcIdx))
              idx = fromIntegral (unsafePerformIO (cbcArg2 bcIdx))
           in envLookupResolved level idx env
        0 {- LIT_INT -} ->
          let lo = unsafePerformIO (cbcArg1 bcIdx)
              hi = unsafePerformIO (cbcArg2 bcIdx)
              w64 = fromIntegral lo .|. (fromIntegral hi `shiftL` 32) :: Word64
           in Thunk (newComputedIntPtr (fromIntegral w64 :: Int64))
        1 {- LIT_FLOAT -} ->
          let lo = unsafePerformIO (cbcArg1 bcIdx)
              hi = unsafePerformIO (cbcArg2 bcIdx)
              w64 = fromIntegral lo .|. (fromIntegral hi `shiftL` 32) :: Word64
           in Thunk (newComputedFloatPtr (castWord64ToDouble w64))
        2 {- LIT_BOOL -} ->
          let flag = unsafePerformIO (cbcShortArg bcIdx)
           in Thunk (newComputedBoolPtr (if flag /= 0 then 1 else 0))
        3 {- LIT_NULL -} -> Thunk newComputedNullPtr
        _ -> mkThunkBc env bcIdx

-- | Allocate a fresh C arena thunk cell with bytecode.
--
-- @NOINLINE@ prevents inlining so GHC can't see inside or CSE calls.
-- @seq@ on the expr creates a data dependency that prevents float-out
-- to a top-level CAF — without this, GHC would hoist the
-- @unsafePerformIO@ and share ONE cell across ALL thunks.
--
-- Compiles the Expr to bytecode and stores (bc_idx, StablePtr Env)
-- in the C thunk.  The Expr tree is eliminated from the GHC heap —
-- only the StablePtr Env (~16 bytes) remains for knot-tying laziness.
{-# NOINLINE newBcThunkPtr #-}
newBcThunkPtr :: Expr -> Env -> CThunkPtr
newBcThunkPtr expr env =
  unsafePerformIO $ expr `seq` do
    bcIdx <- compileExpr expr
    sp <- newStablePtr env
    cthunkNewBc bcIdx (castStablePtrToPtr sp)

-- | Like 'newBcThunkPtr' but keyed on the env's C pointer instead
-- of the expression.  Used by 'mkSyntheticThunk' where multiple thunks
-- share the same expression (e.g. 'deferApplyExpr').
{-# NOINLINE newSyntheticBcThunkPtr #-}
newSyntheticBcThunkPtr :: Ptr NnEnv -> Expr -> Env -> CThunkPtr
newSyntheticBcThunkPtr envKey expr env =
  unsafePerformIO $ envKey `seq` do
    bcIdx <- compileExpr expr
    sp <- newStablePtr env
    cthunkNewBc bcIdx (castStablePtrToPtr sp)

-- | Allocate a fresh C arena thunk cell from a known bytecode index.
-- No compilation needed — the bc_idx is already available.
--
-- Uses StablePtr for the Env so it stays lazy — essential for
-- knot-tying in recursive attrs, let bindings, and matchFormalSet.
-- The StablePtr points to an Env (newtype around Ptr NnEnv) — ~16
-- bytes on the GHC heap, negligible compared to the Expr trees we
-- eliminated.
{-# NOINLINE newBcThunkPtrLazy #-}
newBcThunkPtrLazy :: Word32 -> Env -> CThunkPtr
newBcThunkPtrLazy bcIdx env =
  unsafePerformIO $ bcIdx `seq` do
    sp <- newStablePtr env
    cthunkNewBc bcIdx (castStablePtrToPtr sp)

-- | Wrap an already-computed value as a thunk.
-- Scalars (int/float/bool/null) use inline C thunks (no StablePtr).
-- Attrs, paths, and context-free strings use C-native tags (no StablePtr).
-- Other complex values fall back to StablePtr-backed C thunks.
evaluated :: NixValue -> Thunk
evaluated (VInt n) = Thunk (newComputedIntPtr n)
evaluated (VFloat d) = Thunk (newComputedFloatPtr d)
evaluated (VBool b) = Thunk (newComputedBoolPtr (if b then 1 else 0))
evaluated VNull = Thunk newComputedNullPtr
evaluated (VAttrs (AttrSet cset)) = Thunk (newComputedAttrsPtr cset)
evaluated (VPath p) = Thunk (newComputedPathPtr p)
evaluated (VStr t ctx)
  | ctx == emptyContext = Thunk (newComputedStrPtr t)
  | otherwise = Thunk (newComputedCtxStrPtr t ctx)
evaluated (VList cl) = Thunk (newComputedListPtrC cl)
evaluated (VLambda env formals bodyBcIdx) = Thunk (newComputedLambdaPtr env formals bodyBcIdx)
evaluated val = Thunk (newComputedThunkPtr val)

-- | Check if two thunks share the same memoization cell (C pointer
-- equality).  When true, both thunks will always produce the same value,
-- so deep equality can short-circuit to 'True' without forcing.
thunkSameRef :: Thunk -> Thunk -> Bool
thunkSameRef (Thunk p1) (Thunk p2) = p1 == p2

-- | Extract the C pointer from a thunk (zero-cost, newtype unwrap).
thunkToCPtr :: Thunk -> CThunkPtr
thunkToCPtr (Thunk ptr) = ptr

-- | Wrap an already-computed 'NixValue' in a C thunk (StablePtr path).
-- Arena-allocated (O(1)).  Used for complex values (string, list, attrs, etc.).
{-# NOINLINE newComputedThunkPtr #-}
newComputedThunkPtr :: NixValue -> CThunkPtr
newComputedThunkPtr val =
  unsafePerformIO $ val `seq` do
    sp <- newStablePtr val
    cthunkNewComputed (castStablePtrToPtr sp)

-- | Wrap an int64 in a pre-computed C thunk (inline, no StablePtr).
{-# NOINLINE newComputedIntPtr #-}
newComputedIntPtr :: Int64 -> CThunkPtr
newComputedIntPtr n = unsafePerformIO (cthunkNewComputedInt n)

-- | Wrap a double in a pre-computed C thunk (inline, no StablePtr).
{-# NOINLINE newComputedFloatPtr #-}
newComputedFloatPtr :: Double -> CThunkPtr
newComputedFloatPtr d = unsafePerformIO (cthunkNewComputedFloat d)

-- | Wrap a bool in a pre-computed C thunk (inline, no StablePtr).
{-# NOINLINE newComputedBoolPtr #-}
newComputedBoolPtr :: Word8 -> CThunkPtr
newComputedBoolPtr b = unsafePerformIO (cthunkNewComputedBool b)

-- | Wrap null in a pre-computed C thunk (inline, no StablePtr).
{-# NOINLINE newComputedNullPtr #-}
newComputedNullPtr :: CThunkPtr
newComputedNullPtr = unsafePerformIO cthunkNewComputedNull

-- | Wrap a context-free string in a pre-computed C thunk (interned symbol, no StablePtr).
{-# NOINLINE newComputedStrPtr #-}
newComputedStrPtr :: Text -> CThunkPtr
newComputedStrPtr t =
  unsafePerformIO $ t `seq` do
    Symbol sym <- symbolIntern t
    cthunkNewComputedStr sym

-- | Wrap a path in a pre-computed C thunk (interned symbol, no StablePtr).
{-# NOINLINE newComputedPathPtr #-}
newComputedPathPtr :: Text -> CThunkPtr
newComputedPathPtr p =
  unsafePerformIO $ p `seq` do
    Symbol sym <- symbolIntern p
    cthunkNewComputedPath sym

-- | Wrap a CList in a pre-computed C thunk (no conversion needed).
{-# NOINLINE newComputedListPtrC #-}
newComputedListPtrC :: CList -> CThunkPtr
newComputedListPtrC (CList clistPtr) =
  unsafePerformIO $
    clistPtr `seq`
      cthunkNewComputedList (castPtr clistPtr)

-- | Wrap a string with context in a pre-computed C thunk (no StablePtr).
-- Interns the text and all StorePath fields as symbols, builds nn_ctxstr_t.
{-# NOINLINE newComputedCtxStrPtr #-}
newComputedCtxStrPtr :: Text -> StringContext -> CThunkPtr
newComputedCtxStrPtr t ctx =
  unsafePerformIO $
    t `seq`
      ctx `seq` do
        ptr <- marshalStringContext t ctx
        cthunkNewComputedCtxStr (castPtr ptr)

-- | Wrap a lambda closure in a pre-computed C thunk (no StablePtr).
-- Marshals EvalFormals to nn_lambda_t, stores as tag 9.
{-# NOINLINE newComputedLambdaPtr #-}
newComputedLambdaPtr :: Env -> EvalFormals -> Word32 -> CThunkPtr
newComputedLambdaPtr (Env envPtr) formals bodyBcIdx =
  unsafePerformIO $
    formals `seq` do
      clam <- marshalLambda envPtr formals bodyBcIdx
      cthunkNewComputedLambda clam

-- | Marshal Env + EvalFormals + body bc_idx to a C nn_lambda_t.
marshalLambda :: Ptr NnEnv -> EvalFormals -> Word32 -> IO (Ptr ())
marshalLambda envPtr formals bodyBcIdx = case formals of
  EFName name -> do
    Symbol nameSym <- symbolIntern name
    lam <- clambdaNew envPtr bodyBcIdx 0 nameSym 0 0
    pure (castPtr lam)
  EFSet entries allowExtra -> do
    let count = fromIntegral (length entries) :: Word16
        extraFlag = if allowExtra then 1 else 0 :: Word8
    lam <- clambdaNew envPtr bodyBcIdx 1 0 extraFlag count
    fillEntries lam 0 entries
    pure (castPtr lam)
  EFNamedSet name entries allowExtra -> do
    Symbol nameSym <- symbolIntern name
    let count = fromIntegral (length entries) :: Word16
        extraFlag = if allowExtra then 1 else 0 :: Word8
    lam <- clambdaNew envPtr bodyBcIdx 2 nameSym extraFlag count
    fillEntries lam 0 entries
    pure (castPtr lam)
  where
    fillEntries _ _ [] = pure ()
    fillEntries lam !idx (EvalFormal name defBcIdx : rest) = do
      Symbol nameSym <- symbolIntern name
      let (hasDef, defIdx) = case defBcIdx of
            Nothing -> (0, 0)
            Just di -> (1, di)
      clambdaSetEntry lam idx nameSym hasDef defIdx
      fillEntries lam (idx + 1) rest

-- | Read a C nn_lambda_t back into VLambda.
-- Reconstructs Env (newtype wrap), EvalFormals (from symbol IDs), body bc_idx.
unmarshalLambdaValue :: Ptr () -> IO NixValue
unmarshalLambdaValue rawPtr = do
  let lamPtr = castPtr rawPtr
  envPtr <- clambdaEnv lamPtr
  bodyIdx <- clambdaBody lamPtr
  formalsType <- clambdaFormalsType lamPtr
  formals <- case formalsType of
    0 {- Name -} -> do
      nameSym <- clambdaNameSym lamPtr
      pure (EFName (symbolText (Symbol nameSym)))
    1 {- Set -} -> do
      count <- clambdaFormalCount lamPtr
      extra <- clambdaAllowExtra lamPtr
      entries <- readLambdaEntries lamPtr count
      pure (EFSet entries (extra /= 0))
    _ {- NamedSet -} -> do
      nameSym <- clambdaNameSym lamPtr
      count <- clambdaFormalCount lamPtr
      extra <- clambdaAllowExtra lamPtr
      entries <- readLambdaEntries lamPtr count
      pure (EFNamedSet (symbolText (Symbol nameSym)) entries (extra /= 0))
  pure (VLambda (Env envPtr) formals bodyIdx)
  where
    readLambdaEntries lamPtr count = mapM (readOneEntry lamPtr) [0 .. count - 1]
    readOneEntry lamPtr idx = do
      nameSym <- clambdaEntryName lamPtr idx
      hasDef <- clambdaEntryHasDefault lamPtr idx
      defIdx <- clambdaEntryDefault lamPtr idx
      let defMaybe = if hasDef /= 0 then Just defIdx else Nothing
      pure (EvalFormal (symbolText (Symbol nameSym)) defMaybe)

-- | Marshal Text + StringContext to a C nn_ctxstr_t.
marshalStringContext :: Text -> StringContext -> IO CCtxStrPtr
marshalStringContext textVal (StringContext ctxSet) = do
  Symbol textSym <- symbolIntern textVal
  let elems = Set.toAscList ctxSet
      count = fromIntegral (length elems) :: Word16
  ptr <- cctxstrNew textSym count
  fillElems ptr 0 elems
  pure ptr
  where
    fillElems _ _ [] = pure ()
    fillElems ptr !idx (e : es) = do
      marshalElem ptr idx e
      fillElems ptr (idx + 1) es
    marshalElem eptr eidx (SCPlain (StorePath h n)) = do
      Symbol hashSym <- symbolIntern h
      Symbol nameSym <- symbolIntern n
      cctxstrSetPlain eptr eidx hashSym nameSym
    marshalElem eptr eidx (SCDrvOutput (StorePath h n) out) = do
      Symbol hashSym <- symbolIntern h
      Symbol nameSym <- symbolIntern n
      Symbol outSym <- symbolIntern out
      cctxstrSetDrvOutput eptr eidx hashSym nameSym outSym
    marshalElem eptr eidx (SCAllOutputs (StorePath h n)) = do
      Symbol hashSym <- symbolIntern h
      Symbol nameSym <- symbolIntern n
      cctxstrSetAllOutputs eptr eidx hashSym nameSym

-- | Unmarshal a C nn_ctxstr_t back to (Text, StringContext).
unmarshalStringContext :: CCtxStrPtr -> IO (Text, StringContext)
unmarshalStringContext ptr = do
  textSym <- cctxstrText ptr
  count <- cctxstrCtxCount ptr
  let textVal = symbolText (Symbol textSym)
  elems <- mapM (readElem ptr) [0 .. count - 1]
  pure (textVal, StringContext (Set.fromList elems))
  where
    readElem cptr idx = do
      tag <- cctxstrElemTag cptr idx
      hashSym <- cctxstrElemHash cptr idx
      nameSym <- cctxstrElemName cptr idx
      let sp = StorePath (symbolText (Symbol hashSym)) (symbolText (Symbol nameSym))
      case tag of
        0 -> pure (SCPlain sp)
        1 -> do
          outSym <- cctxstrElemOutput cptr idx
          pure (SCDrvOutput sp (symbolText (Symbol outSym)))
        _ -> pure (SCAllOutputs sp)

-- | Wrap a CAttrSet in a pre-computed C thunk (pointer, no StablePtr).
{-# NOINLINE newComputedAttrsPtr #-}
newComputedAttrsPtr :: CAttrSet -> CThunkPtr
newComputedAttrsPtr cset =
  unsafePerformIO $
    cset `seq`
      cthunkNewComputedAttrs (castPtr cset)

-- | Build a C-allocated slot array from a list of 'Thunk' values.
-- Each thunk is converted to 'CThunkPtr' via 'thunkToCPtr'.
-- Returns the C array pointer and the slot count.
-- Uses 'unsafePerformIO' — safe because allocation is idempotent.
{-# NOINLINE buildCSlots #-}
buildCSlots :: [Thunk] -> (Ptr CThunkPtr, Int)
buildCSlots thunks = unsafePerformIO $ do
  let n = length thunks
  if n == 0
    then pure (nullPtr, 0)
    else do
      arr <- cenvAllocSlots (fromIntegral n)
      pokeSlots arr 0 thunks
      pure (arr, n)
  where
    pokeSlots _ _ [] = pure ()
    pokeSlots arr i (t : ts) = do
      pokeElemOff arr i (thunkToCPtr t)
      pokeSlots arr (i + 1) ts

-- | Allocate a C slot array of the given size WITHOUT filling it.
-- Returns 'nullPtr' for size 0.  Used by knot-tying sites (rec {},
-- let) where the Env must reference the slot pointer before the
-- thunks are materialized.  Caller MUST fill all slots via 'fillCSlots'
-- before any slot is read (e.g. before forcing any thunk).
{-# NOINLINE allocCSlots #-}
allocCSlots :: Int -> Ptr CThunkPtr
allocCSlots 0 = nullPtr
allocCSlots n = unsafePerformIO (cenvAllocSlots (fromIntegral n))

-- | Fill a pre-allocated C slot array with thunks.  Each thunk is
-- converted to 'CThunkPtr' via 'thunkToCPtr' and poked at its index.
-- The list length MUST equal the allocated array size.
-- Used after 'allocCSlots' in knot-tying contexts.
{-# NOINLINE fillCSlots #-}
fillCSlots :: Ptr CThunkPtr -> [Thunk] -> ()
fillCSlots arr thunks = unsafePerformIO (go 0 thunks)
  where
    go _ [] = pure ()
    go !i (t : ts) = do
      pokeElemOff arr i (thunkToCPtr t)
      go (i + 1) ts

-- | Human-readable type name for error messages.
typeName :: NixValue -> Text
typeName val = case val of
  VInt _ -> "an integer"
  VFloat _ -> "a float"
  VBool _ -> "a Boolean"
  VNull -> "null"
  VStr _ _ -> "a string"
  VPath _ -> "a path"
  VList _ -> "a list"
  VAttrs _ -> "a set"
  VLambda {} -> "a function"
  VDerivation _ -> "a derivation"
  VBuiltin _ _ -> "a built-in function"
  VCompiledRegex _ -> "a built-in function"

-- ---------------------------------------------------------------------------
-- Evaluation monad
-- ---------------------------------------------------------------------------

-- | Effect class for Nix evaluation.  Core logic is polymorphic in @m@
-- so the same evaluator composes into 'PureEval' for tests or @IO@ for
-- real file-system access (e.g. @import@, @readFile@).
class (Monad m) => MonadEval m where
  throwEvalError :: Text -> m a

  -- | Abort evaluation (uncatchable by tryEval, matching real Nix).
  abortEvaluation :: Text -> m a

  catchEvalError :: m a -> m (Either Text a)
  readFileText :: Text -> m Text
  doesPathExist :: Text -> m Bool

  -- | List a directory, returning @(name, fileType)@ pairs.
  -- @fileType@ is one of @"regular"@, @"directory"@, or @"symlink"@
  -- (matching Nix's @builtins.readDir@ semantics).
  listDirectory :: Text -> m [(Text, Text)]

  importFile :: Text -> m NixValue

  -- | Look up an environment variable.  Returns @""@ if unset.
  getEnvVar :: Text -> m Text

  -- | Get the current epoch time (seconds since 1970-01-01).
  getCurrentTime :: m Int64

  -- | Write a named file to the store, returning the store path.
  writeToStore :: Text -> Text -> m Text

  -- | Import a file with a custom scope overlaid on builtins.
  scopedImportFile :: [(Text, Thunk)] -> Text -> m NixValue

  -- | Read raw bytes from a file.  Used by @builtins.hashFile@.
  readFileBytes :: Text -> m ByteString

  -- | Classify a single filesystem path as @"regular"@, @"directory"@,
  -- @"symlink"@, or @"unknown"@.  Used by @builtins.readFileType@.
  getFileType :: Text -> m Text

  -- | Run an external process: @(command, args, stdin) -> (exitCode, stdout, stderr)@.
  runProcess :: Text -> [Text] -> Text -> m (Int, Text, Text)

  -- | Resolve a path literal to an absolute path.
  -- In IO evaluation, relative paths are resolved against the current
  -- file's directory (@esBaseDir@).  In pure evaluation, paths are
  -- returned unchanged.  This ensures that path values captured in
  -- closures remain valid after the import scope ends.
  resolvePathLiteral :: Text -> m Text

  -- | Copy a path (file or directory) to the store, returning the store path.
  -- First argument is the source path, second is the store name.
  copyPathToStore :: Text -> Text -> m Text

  -- | Print a trace/warning message.
  -- IO evaluators write to stderr; pure evaluators silently discard.
  traceMessage :: Text -> m ()

  -- | Force a thunk to a value, with memoization.
  -- IO evaluators should cache results (via per-thunk @IORef@).
  -- Pure evaluators re-evaluate each time.
  -- The first argument is the bytecode evaluation function (to break
  -- the Eval.Types → Eval circular dependency).
  forceThunk :: (Env -> Word32 -> m NixValue) -> Thunk -> m NixValue

  -- | Look up a cached derivation modulo-hash (hex) by its @.drv@ store
  -- path.  Populated bottom-up as each derivation is computed; used by the
  -- derivation-hash algorithm to substitute input derivations.  Pure
  -- evaluators have no cache and always return 'Nothing'.
  lookupDrvHash :: Text -> m (Maybe Text)

  -- | Cache a derivation's modulo hash (hex) under its @.drv@ store path.
  -- A no-op in pure evaluators (no memoization).
  cacheDrvHash :: Text -> Text -> m ()

  -- | Compute the store path a source file/directory gets when copied into
  -- the store (recursive NAR sha256 → @source@ fixed-output path), WITHOUT
  -- performing the copy.  Used when a path literal is coerced in a derivation
  -- argument or environment value.  Unavailable in pure evaluation.
  storeSourcePath :: Text -> m Text

-- | Pure evaluation monad — wraps 'Either Text'.
-- IO builtins ('readFile', 'import') are unavailable;
-- everything else evaluates identically to the IO version.
newtype PureEval a = PureEval {runPureEval :: Either Text a}
  deriving (Functor, Applicative, Monad)

instance MonadEval PureEval where
  throwEvalError msg = PureEval (Left msg)
  abortEvaluation msg = PureEval (Left ("evaluation aborted: " <> msg))
  catchEvalError (PureEval action) = PureEval (Right action)
  readFileText _ = throwEvalError "readFile: not available in pure evaluation"
  doesPathExist _ = pure False
  listDirectory _ = throwEvalError "builtins.readDir: not available in pure evaluation"
  importFile _ = throwEvalError "import: not available in pure evaluation"
  getEnvVar _ = pure ""
  getCurrentTime = pure 0
  writeToStore _ _ = throwEvalError "toFile: not available in pure evaluation"
  scopedImportFile _ _ = throwEvalError "scopedImport: not available in pure evaluation"
  readFileBytes _ = throwEvalError "hashFile: not available in pure evaluation"
  getFileType _ = throwEvalError "readFileType: not available in pure evaluation"
  runProcess _ _ _ = throwEvalError "runProcess: not available in pure evaluation"
  copyPathToStore _ _ = throwEvalError "builtins.path: not available in pure evaluation"
  traceMessage _ = pure ()
  lookupDrvHash _ = pure Nothing
  cacheDrvHash _ _ = pure ()

  -- Pure eval cannot read files: a path coerces to itself (no store copy);
  -- the real copy-to-store happens only under 'EvalIO'.
  storeSourcePath = pure
  resolvePathLiteral = pure
  forceThunk evalFn (Thunk ptr) =
    -- Read the C thunk via unsafePerformIO — safe because reads are
    -- idempotent and PureEval never writes back (no memoization).
    -- Does NOT mark blackholes — PureEval may re-force the same thunk.
    -- Dispatches on val_tag for computed scalars (no StablePtr deref).
    case unsafePerformIO (cthunkState ptr) of
      1 {- COMPUTED -} ->
        let tag = unsafePerformIO (cthunkValueTag ptr)
         in case tag of
              0 {- INT -} -> pure (VInt (unsafePerformIO (cthunkGetInt ptr)))
              1 {- FLOAT -} -> pure (VFloat (unsafePerformIO (cthunkGetFloat ptr)))
              2 {- BOOL -} -> pure (VBool (unsafePerformIO (cthunkGetBool ptr) /= 0))
              3 {- NULL -} -> pure VNull
              4 {- STR -} ->
                let sym = unsafePerformIO (cthunkGetStr ptr)
                 in pure (VStr (symbolText (Symbol sym)) emptyContext)
              5 {- PATH -} ->
                let sym = unsafePerformIO (cthunkGetPath ptr)
                 in pure (VPath (symbolText (Symbol sym)))
              6 {- LIST -} ->
                let listPtr = unsafePerformIO (cthunkGetList ptr)
                 in pure (VList (CList (castPtr listPtr)))
              7 {- ATTRS -} ->
                let p = unsafePerformIO (cthunkGetAttrs ptr)
                 in pure (VAttrs (AttrSet (castPtr p)))
              8 {- CTXSTR -} ->
                let csptr = unsafePerformIO (cthunkGetCtxStr ptr)
                    (t, ctx) = unsafePerformIO (unmarshalStringContext (castPtr csptr))
                 in pure (VStr t ctx)
              9 {- LAMBDA -} ->
                let lamRaw = unsafePerformIO (cthunkPayload ptr)
                    val = unsafePerformIO (unmarshalLambdaValue lamRaw)
                 in pure val
              _ {- PTR -} ->
                let payload = unsafePerformIO (cthunkPayload ptr)
                    val = unsafePerformIO (deRefStablePtr (castPtrToStablePtr payload))
                 in pure val
      2 {- BLACKHOLE -} ->
        -- Non-catchable: must escape tryEval like in C++ Nix
        abortEvaluation "infinite recursion encountered"
      _ {- PENDING -} ->
        let bcIdx = unsafePerformIO (cthunkGetBcIdx ptr)
            envSp = unsafePerformIO (cthunkPayload ptr)
            env = unsafePerformIO (deRefStablePtr (castPtrToStablePtr envSp))
         in evalFn env bcIdx