Skip to content

AcceptedOwner sparkles:nix

sparkles:nix — Specification ​

Abstract ​

sparkles:nix embeds the Nix evaluator in D programs. Through it a program opens a Nix store, evaluates Nix expressions and flakes, builds derivations, and reads the results back as typed D values, all in-process rather than by running the Nix command-line tool and parsing its output. It binds Nix's stable C API in two layers: a raw layer generated from the C headers themselves, so it cannot drift from the Nix it links against, and a memory-safe layer that owns every handle, releases it exactly once, and returns each ordinary failure as a value instead of throwing it.

Introduction ​

Tools that work with Nix need the values a Nix expression produces: the outputs of a flake, a package's version, a store path to build. The usual route runs the nix command and parses its JSON output. That costs a process and a fresh evaluation per question, forces everything it prints, and reports errors as text on standard error. Nix also ships a C API for embedding its evaluator in-process, and the Rust nix-bindings-rust workspace, on which nixops4 and devenv build, shows that real tools can rely on it.

The C API is not usable as-is from safe code. Each call reports failure through a separate error context rather than its return value. Each handle must be released by hand, some exactly once and some by reference count. Strings arrive through callbacks. The evaluator runs its own Boehm garbage collector, which scans only the threads it has registered, yet the C API offers no way to register a thread. A binding that leaves these rules implicit makes every caller rediscover them as lost errors, leaks, double frees, and collector crashes on the wrong thread.

The library follows the split nix-bindings-rust draws between raw and safe crates, collapsed into one package. The raw layer is ImportC compiling the real Nix headers, so every C declaration is callable from D with exactly the layout of the installed Nix; it adds nothing. Calls that need a newer Nix than the linked one are compiled out by a build-time version check. The wrapper layer above the raw one turns each convention of the C API into a D one. The error context becomes a returned result carrying both code and message, manual release becomes handles that free their resource exactly once, string callbacks become D output ranges, and the collector's thread rule becomes an explicit single-thread contract. Ordinary failures, such as a bad expression or a missing attribute, are results; a broken C contract is an assertion. A small facade over the wrapper layer bundles initialization, a store, and an evaluator for programs that only want to evaluate an expression.

Evaluation is confined to one thread. The thread that initializes Nix performs all evaluation, which includes forcing a lazy value and extracting its contents; other threads may only hold, copy, and release value handles. Multi-threaded evaluation, which needs a direct binding to the Boehm collector, is out of scope, as are Nix functions implemented by D callbacks, Nix's external values (opaque host objects inside Nix values), a persistent evaluation cache such as devenv's, and package or SBOM models built on top of the store. The wrapper layer allocates on D's garbage-collected heap, because an evaluator that runs its own collector cannot be allocation-free; only a few string sinks avoid it. Callers who need an unbound part of the C API may use the raw layer directly, outside the wrapper's safety guarantees.

§1 restates the two layers and their rules as the contract at a glance, §2 lays out the modules, and §3 the raw bindings. §4 and §5 define error handling and string bridging, and §6 the library lifecycle, settings, and the normative threading and collector model. §7–§10 specify the store, evaluation, values, and flakes, and §11 the facade. §12 collects the safety conventions, §13 the example application, and §14 the non-goals. Delivery order and risks live in PLAN.md, and the binding workflow in Integrating C Libraries.

1. Overview ​

sparkles:nix lets D programs embed the Nix evaluator: open a store, evaluate Nix expressions and flakes, and extract typed values — by binding Nix's stable C API (libnixutil-c, libnixstore-c, libnixexpr-c, libnixfetchers-c, libnixflake-c, libnixmain-c).

It has two layers, in the same spirit as nix-bindings-rust's *-sys + safe-wrapper split:

  1. Raw bindings (sparkles.nix.c) — an ImportC shim that #includes the real Nix C headers, so every C declaration is a callable D symbol whose layout can never drift from upstream. This is the analogue of libs/ghostty and the *-sys crates.
  2. High-level wrappers (sparkles.nix.* D modules) — idiomatic, memory-safe D over the raw layer: RAII handles, Expected-based errors, output-range string bridging, and lazy/strict value extraction. This is the analogue of the safe nix-bindings-{util,store,expr,fetchers,flake} crates, collapsed into one dub package.

Core rules:

  • The raw layer is a faithful, attribute-stamped (nothrow @nogc) view of the C ABI; it adds nothing. All ergonomics live in the wrapper layer.
  • Every fallible wrapper operation returns NixResult!T (Expected!(T, NixError)) — no exceptions cross the API for ordinary failures (bad expression, type mismatch, missing attribute, build failure). Programmer/contract violations (a C function returning null without setting an error, context-allocation failure) are asserts/Errors, not NixResult.
  • Resource handles (Store, StorePath, EvalState, Value, the flake types) are RAII structs that free/decref exactly once. Shared handles are reference-counted; value handles participate in Nix's GC refcount.
  • The C API has no thread-registration entry point; the library is therefore single-eval-thread by contract (§6.3).

A consumer who only wants to evaluate an expression touches four names:

d
import sparkles.nix;

auto nix = NixSession.open().value;            // init + GC + dummy store + eval state
auto v   = nix.eval("1 + 2").value;            // NixResult!Value
assert(nix.requireInt(v).value == 3);

2. Package and module layout ​

IdentifierValue
Dub sub-packagesparkles:nix
Source rootlibs/nix/src/sparkles/nix/
Package modulesparkles.nix

libs/nix is one dub package with internal modules mirroring the Nix C library layering (util → store → expr → fetchers → flake), not a package per layer.

ModuleContents
sparkles.nixPublic re-exports (package.d) + the NixSession convenience facade
sparkles.nix.cRaw ImportC bindings (generated by compiling c.c) — import sparkles.nix.c; for the raw ABI
sparkles.nix.errorNixErrCode, NixError, NixResult!T, NixContext, checkCall
sparkles.nix.stringsnix_get_string_callback ↔ output-range bridge (StringSink, collectString)
sparkles.nix.libraryinitNix, nixVersion, getSetting/setSetting, the GcThreadGuard stub (§6.3)
sparkles.nix.storeStore, StorePath
sparkles.nix.evalEvalState, EvalStateBuilder; the require*/mk*/call* value operations (they need a state)
sparkles.nix.valueValueType, Value (refcounted handle), the package-visible adopt/retain factories
sparkles.nix.flakeFetchersSettings, FlakeSettings, FlakeReference, FlakeReferenceParseFlags, FlakeLockFlags, LockedFlake

Module-dependency direction matches the C layering and the Rust crates: error/strings are the base; library and store build on them; value is standalone (just the handle); eval depends on store + value; flake depends on eval. package.d re-exports all and adds NixSession.

libs/nix/src/sparkles/nix/c.c is the ImportC shim; it includes the whole public C surface (the flake header transitively pulls in fetchers/store/util/ expr; value + external + main are included explicitly):

c
// The Nix C API is stateful (it mutates global settings, opens stores, runs
// a GC) so `pure` is omitted; `nothrow @nogc` is accurate — the C functions
// neither throw D exceptions nor use the D GC. See importc-c-libraries.md.
#pragma attribute(push, nogc, nothrow)
#include <nix_api_util.h>
#include <nix_api_main.h>
#include <nix_api_store.h>
#include <nix_api_value.h>
#include <nix_api_external.h>
#include <nix_api_expr.h>
#include <nix_api_fetchers.h>
#include <nix_api_flake.h>
#pragma attribute(pop)

3. The raw bindings layer ​

sparkles.nix.c exposes the Nix C ABI verbatim. Rules:

  • It is targetType "sourceLibrary" so dub's pkg-config --cflags reach ImportC (the sourceLibrary gotcha).
  • dub.sdl lists the link libraries by their pkg-config package names (which match the .pc files, so dub resolves both --cflags and --libs): libs "nix-util-c" "nix-store-c" "nix-expr-c" "nix-fetchers-c" "nix-flake-c" "nix-main-c". Each .pc Libs: field emits the real link flag (-lnixstorec, …); transitive deps come via the .pc Requires: field.
  • ImportC enum members are enum-scoped in D: write nix_err.NIX_OK, ValueType.NIX_TYPE_INT. The wrapper layer defines clean D enums (NixErrCode, NixValueType) so consumers never touch the raw names.
  • Function-like and non-trivial macros do not import: NIX_VALUE_CALL (variadic) and NIX_DEPRECATED are re-expressed in D (the former as EvalState.callMulti, the latter ignored — bind nix_value, not the deprecated Value alias).
  • Three distinct C callback shapes exist and must be kept as separate extern(C) function-pointer aliases (the wrapper layer does this): the generic string sink (const char* start, uint n, void* user_data); the realise output (void* userdata, const char* outname, const StorePath* out); and the fs-closure (nix_c_context* ctx, void* userdata, const StorePath* sp).

Consumers needing an unbound corner of the API can import sparkles.nix.c; and call the raw symbols directly; this is supported but unsafe.

Known ImportC risk. nix_api_value.h declares the deprecated Value alias with a raw, unguarded C23 attribute ([[deprecated("use nix_value instead")]] typedef nix_value Value;). The build must parse this; if a given LDC/DMD ImportC rejects [[…]] attribute syntax, the shim/derivation strips it. See PLAN.md.

4. Error handling ​

Every Nix C function reports failure through an out-of-band nix_c_context* (first argument), not its return value. The wrapper layer captures both the code and the message into a value-type error.

d
/// Mirror of the C `nix_err` enum, as a clean D enum.
enum NixErrCode : int
{
    ok          =  0,   /// NIX_OK
    unknown     = -1,   /// NIX_ERR_UNKNOWN — non-nix C++ exception
    overflow    = -2,   /// NIX_ERR_OVERFLOW
    key         = -3,   /// NIX_ERR_KEY — unknown setting / missing attr / OOB index
    nixError    = -4,   /// NIX_ERR_NIX_ERROR — a nix::Error (eval/build failure)
    recoverable = -5,   /// NIX_ERR_RECOVERABLE — transient primop failure
}

/// A captured Nix error: the code plus a copy of the borrowed message
/// (the C `nix_err_msg` pointer is only valid until the next call, so it
/// is copied out immediately). `message` is a GC string — the high-level
/// layer is GC-using by design (§12), so this is consistent.
struct NixError
{
    NixErrCode code;
    string message;
    void toString(W)(ref W w) const;   // "<code>: <message>"
}

/// The result type of every fallible wrapper operation.
alias NixResult(T) = Expected!(T, NixError);

The two construction helpers (cf. parseOk/parseErr in versions):

d
NixResult!T nixOk(T)(T value);          // success
NixResult!T nixErr(T)(NixError e);      // failure
NixResult!void nixOk();                 // success, no payload

NixContext is the RAII wrapper over nix_c_context* plus the call helper. A context carries no meaningful state between calls (every C function clears it on entry and checkCall clears it on error), so it is reused — each Store/EvalState caches one to avoid per-call allocation.

d
struct NixContext               // move-only
{
    @disable this(this);
    static NixContext create();          // nix_c_context_create; asserts non-null
    ~this();                             // nix_c_context_free
    nix_c_context* ptr() return;
    NixResult!void check();              // read nix_err_code; on !ok copy nix_err_msg → NixError; clear
}

checkCall is the workhorse: it splices the context pointer in as the C function's first argument, invokes it, then checks. It returns NixResult!void for nix_err-returning calls (the code is redundant with the context) and NixResult!R for value/pointer-returning calls.

d
/// `checkCall!nix_store_open(ctx, uri, params)` → NixResult!(Store*)
/// `checkCall!nix_value_force(ctx, state, v)`   → NixResult!void
template checkCall(alias fn)
{
    auto checkCall(Args...)(ref NixContext ctx, auto ref Args args);
}

checkCallOptKey is the variant that maps NIX_ERR_KEY to a "not found" rather than an error — its result is NixResult!(Nullable!T), yielding a null Nullable when the key is absent. This backs requireAttrOpt (§9.4).

5. String bridging ​

Nix returns strings by invoking a caller-supplied callback with a borrowed, possibly non-NUL-terminated (start, n) buffer, never by handing out ownership. The wrapper bridges this to a D output range — the same move core_cli makes everywhere (cf. prettyPrint's Writer).

d
/// `extern(C)` trampoline: recovers the output range from `userData` and
/// `put`s the borrowed slice. One instantiation per writer type `W`.
extern(C) void stringSinkCallback(W)(const(char)* start, uint n, void* userData) nothrow;

/// Drive a string-returning C function into `writer`. `call` is a delegate
/// that invokes the C function with the supplied callback + user_data, e.g.
///   collectStringInto(ctx, w, (cb, ud) => nix_get_string(ctx.ptr, v, cb, ud));
NixResult!void collectStringInto(W, Call)(ref NixContext ctx, ref W writer, scope Call call);

/// Convenience: collect into a fresh GC string.
NixResult!string collectString(Call)(ref NixContext ctx, scope Call call);

Invariants carried over from the Rust bridge: a null start with n == 0 yields the empty string; the sink is written exactly once. The @nogccollectStringInto form lets callers sink into a SmallBuffer; the collectString form is the GC convenience used by most of the API.

6. Library lifecycle, settings, threading & GC ​

6.1 Initialization ​

d
/// Idempotent, process-global, run-once. Chains nix_libutil_init →
/// nix_libstore_init → nix_libexpr_init (which initializes the Boehm GC and
/// registers the calling thread). Caches a value-type NixError so repeat
/// calls return the same result cheaply. Must run before opening a Store or
/// building an EvalState.
NixResult!void initNix();

/// The linked libnix version (`nix_version_get`), e.g. "2.35.0".
string nixVersion();

6.2 Settings ​

Nix's settings are process-global mutable state with no internal locking, so getSetting/setSetting are guarded by a library mutex and are intended for single-threaded startup configuration (e.g. enabling experimental features before building an EvalState).

d
NixResult!void   setSetting(in char[] key, in char[] value);   // nix_setting_set
NixResult!string getSetting(in char[] key);                    // nix_setting_get (string sink)

/// Enabling flakes is the common case (an experimental feature):
///   setSetting("experimental-features", "flakes");

6.3 Threading & GC model (normative constraint) ​

The Nix C API exposes no thread-registration function. initNix (via nix::initGC()) calls GC_allow_register_threads() and registers the calling thread, but there is no public way to register another thread for Boehm GC stack scanning.

Contract: call initNix and perform all evaluation (build the EvalState, evaluate, force, and extract every Value) on a single thread — the same one that called initNix. This matches how nixops4 and devenv operate (a dedicated, large-stack evaluation thread). Sharing one EvalState across threads, or evaluating on a thread the Nix GC never saw, is undefined.

GcThreadGuard is declared as a no-op placeholder returning success on the init thread, with its multi-thread implementation (binding Boehm GC's <gc/gc.h> directly, as Rust's bdwgc-sys does) listed as future work in PLAN.md. Refcount operations (nix_value_incref/decref) are thread-safe (the C side uses a concurrent map), so passing Value handles between threads is safe; only evaluation is thread-confined.

Recommended for deep expressions: run evaluation on a thread with a large stack (nixops4/devenv use 64 MiB) — an application concern the library documents but does not impose.

7. Store ​

Store is a reference-counted handle (so an EvalState can hold a copy that keeps the store alive for its whole lifetime — the C EvalState owns a reference to the store internally). The free call (nix_store_free) runs once, when the last Store copy drops.

d
struct Store
{
    /// Open a store. `uri` null/empty → the default store; "dummy://" → an
    /// in-memory store needing no daemon (ideal for pure evaluation).
    /// `params` is an optional set of key=value backend parameters.
    static NixResult!Store open(in char[] uri = null,
                                in string[string] params = null);

    string uri();          // nix_store_get_uri        (string sink)
    string storeDir();     // nix_store_get_storedir   (string sink)
    string version_();     // nix_store_get_version    (may be empty)

    NixResult!StorePath parsePath(in char[] path);   // nix_store_parse_path
    NixResult!bool      isValidPath(in StorePath p); // nix_store_is_valid_path

    /// Build/realise a derivation path; returns output-name → realised path.
    /// Bridges the C (outname, outpath) callback into the map; borrowed
    /// StorePaths are cloned before being stored.
    NixResult!(StorePath[string]) realise(in StorePath drvPath);
}

struct StorePath               // single-owner; deep-clones on copy via nix_store_path_clone
{
    string name();             // nix_store_path_name (string sink)
    StorePath dup();           // nix_store_path_clone
    ~this();                   // nix_store_path_free
}

Derivation handling (nix_derivation_*), closure queries (nix_store_get_fs_closure), and copyClosure are part of the C surface and exposed thinly when needed; the core deliverable is store-open + parse/valid/realise (§ PLAN M2). Methods gated on a newer Nix (storeDir ≥ 2.26, realise/derivations ≥ 2.33) are guarded by a build-time version check (§12).

8. Evaluation ​

EvalState is a reference-counted handle holding the C EvalState*, the Store (kept alive), and a cached NixContext. It is built through a fluent builder.

d
struct EvalStateBuilder
{
    static NixResult!EvalStateBuilder create(Store store);   // nix_eval_state_builder_new
    ref EvalStateBuilder lookupPath(scope const(char[])[] entries) return;  // NIX_PATH entries
    ref EvalStateBuilder loadAmbientSettings(bool on = true) return;        // nix.conf/env
    ref EvalStateBuilder flakes(ref FlakeSettings settings) return;         // see §10
    NixResult!EvalState build();                              // nix_eval_state_build (+ _load, _set_lookup_path)
}

struct EvalState
{
    /// Convenience: builder with the given lookup paths and ambient settings on.
    static NixResult!EvalState create(Store store, scope const(char[])[] lookupPath = null);

    /// Parse + evaluate an expression to WHNF (it forces the top value).
    /// `path` is the base dir for relative paths in `expr` (e.g. "<string>").
    NixResult!Value eval(in char[] expr, in char[] path = "<string>");  // nix_expr_eval_from_string
}

Value operations that require the state (forcing, list/attr access, construction, calls) are methods on EvalState (§9), matching the C API where these take an EvalState* and matching nix-bindings-rust.

9. Values ​

9.1 The handle ​

Value is a thin, GC-refcounted handle over nix_value*. Copying a Value increfs (retain); destroying it decrefs (release) — so it behaves like Rust's Clone and a shared_ptr. Refcount errors are swallowed (the only failure mode is a leak), keeping copy/destruction nothrow.

d
struct Value
{
    ValueType type() const;        // discriminant of the *forced* value (THUNK before forcing)
    bool isNull() const;           // handle is empty (no underlying value)
    this(this);                    // nix_value_incref (retain)
    ~this();                       // nix_value_decref (release)

    // package-visible factories used across the library:
    package static Value adopt(nix_value* p);   // take a +1 ref (no incref); decref on drop
    package static Value retain(nix_value* p);   // incref now; decref on drop
}

enum ValueType   // clean mirror of the C ValueType (NIX_TYPE_*)
{
    thunk, int_, float_, bool_, string_, path, null_,
    attrs, list, function_, external, failed,
}

The eval-state lifetime is not encoded in the type (the GC keeps values alive while a Value holds a ref); the invariant — don't use a Value after its EvalState is gone — is documented, not type-enforced. (Same choice as nix-bindings-rust.)

9.2 Forcing ​

d
NixResult!void      force(in Value v);        // nix_value_force (to WHNF)
NixResult!void      forceDeep(in Value v);    // nix_value_force_deep (⚠ stack overflow on cycles)
NixResult!ValueType valueType(in Value v);    // forces if needed, returns the resolved type

9.3 Typed extraction ​

Each require* forces the value to WHNF, type-checks (returning a NixError describing the mismatch on failure), then reads. *Strict additionally forces returned children.

d
NixResult!long    requireInt(in Value v);     // nix_get_int
NixResult!double  requireFloat(in Value v);   // nix_get_float   (fills a gap nix-bindings-rust lacks)
NixResult!bool    requireBool(in Value v);    // nix_get_bool
NixResult!string  requireString(in Value v);  // nix_get_string  (string sink → GC string)
NixResult!void    requireStringInto(W)(in Value v, ref W writer);   // @nogc sink overload
NixResult!string  requirePath(in Value v);    // nix_get_path_string

9.4 Lists and attribute sets ​

d
NixResult!uint        listSize(in Value v);                  // nix_get_list_size
NixResult!(Value[])   requireList(in Value v);              // strict: forces every element
NixResult!(Nullable!Value) listAt(in Value v, uint i);      // forces element i, null if OOB

NixResult!uint        attrCount(in Value v);                 // nix_get_attrs_size
NixResult!Value       requireAttr(in Value v, in char[] name);          // get_attr_byname (required)
NixResult!(Nullable!Value) requireAttrOpt(in Value v, in char[] name);  // NIX_ERR_KEY → null
NixResult!(string[])  attrNames(in Value v);                // sorted (Nix order is unspecified; we sort)

9.5 Construction ​

Mirrors the allocate-uninitialized → init → adopt pattern; fills the creators nix-bindings-rust omits (bool/float/null/path/list).

d
NixResult!Value mkInt(long i);
NixResult!Value mkFloat(double d);
NixResult!Value mkBool(bool b);
NixResult!Value mkNull();
NixResult!Value mkString(in char[] s);
NixResult!Value mkPath(in char[] s);
NixResult!Value mkList(scope Value[] items);        // via ListBuilder
NixResult!Value mkAttrs(scope Tuple!(string, Value)[] entries);  // via BindingsBuilder

ListBuilder / BindingsBuilder are exposed as move-only RAII structs for incremental construction; mkList/mkAttrs are the convenience wrappers.

9.6 Application ​

d
NixResult!Value call(Value fn, Value arg);              // nix_value_call (eager, forces result)
NixResult!Value callMulti(in Value fn, scope Value[] args);  // nix_value_call_multi (currying)
NixResult!Value applyLazy(in Value fn, in Value arg);   // nix_init_apply (thunk; no force)

PrimOps (nix_alloc_primop/nix_init_primop) — building Nix functions from D delegates — are a powerful but advanced facility (nixops4 uses them for dependency injection). They are future work (§ PLAN), not part of the initial surface.

9.7 Realised strings ​

d
struct RealisedString { string text; StorePath[] paths; }

/// Realise the store paths in a string's context and substitute placeholders.
/// Copies everything out then frees the C handle.
NixResult!RealisedString realiseString(in Value v, bool importFromDerivation = false);

10. Flakes ​

Flake support layers on EvalState. The flow mirrors nix-bindings-flake: configure settings → parse a flake reference → lock it → evaluate its output attrset to a Value (then extract with §9). Each type is a move-only RAII handle around its C pointer.

d
struct FetchersSettings { static NixResult!FetchersSettings create(); ~this(); }
struct FlakeSettings    { static NixResult!FlakeSettings create();    ~this(); }

struct FlakeReferenceParseFlags
{
    static NixResult!FlakeReferenceParseFlags create(ref FlakeSettings s);
    ref FlakeReferenceParseFlags baseDirectory(in char[] dir) return;
}

struct FlakeReference
{
    /// Parse "<flakeref>[#<fragment>]" → (reference, fragment).
    static NixResult!(Tuple!(FlakeReference, string)) parse(
        ref FetchersSettings fetch, ref FlakeSettings flake,
        ref FlakeReferenceParseFlags flags, in char[] reference);
}

struct FlakeLockFlags
{
    static NixResult!FlakeLockFlags create(ref FlakeSettings s);
    NixResult!void modeWriteAsNeeded();   // update lockfile if needed
    NixResult!void modeCheck();           // error if out of date
    NixResult!void modeVirtual();         // compute, never write
}

struct LockedFlake
{
    static NixResult!LockedFlake lock(
        ref FetchersSettings fetch, ref FlakeSettings flake,
        ref EvalState state, ref FlakeLockFlags flags, ref FlakeReference ref_);

    /// The result of evaluating the flake's `outputs` — an attrset Value.
    NixResult!Value outputs(ref FlakeSettings flake, ref EvalState state);
}

Flakes require the flakes experimental feature (enable via setSetting("experimental-features", "flakes")) and wiring the FlakeSettings into the eval-state builder via EvalStateBuilder.flakes.

11. The NixSession facade ​

For the common "evaluate some expressions" path, package.d provides a batteries-included facade bundling init + GC + store + eval state:

d
struct NixSession
{
    /// initNix + (dummy or given) Store + EvalState on the calling thread.
    static NixResult!NixSession open(in char[] storeUri = "dummy://",
                                     scope const(char[])[] lookupPath = null);

    NixResult!Value eval(in char[] expr, in char[] path = "<string>");
    // forwards the §9 value operations to the inner EvalState:
    NixResult!long   requireInt(in Value v);
    NixResult!string requireString(in Value v);
    // … etc.

    ref EvalState evalState() return;   // escape hatch to the full API
    ref Store     store() return;
}

12. Safety attributes & conventions ​

  • Raw layer (sparkles.nix.c): nothrow @nogc via the #pragma (not pure — Nix is stateful). @system to call (it is C FFI).
  • Wrapper layer: the wrappers are GC-using by design — they return GC strings, build Value[]/string[], and the underlying C library runs its own (Boehm) GC and allocates. This is a deliberate departure from the repo's @nogc lean: a Nix evaluator cannot be @nogc. Wrappers are @safe where the FFI allows (unsafe pointer work is confined to small @trusted blocks per AGENTS.md, never a @trusted template), and offer @nogc sink overloads (requireStringInto) for hot paths.
  • Errors: NixResult!T for all expected failures; assert/Error only for C contract violations. Use the expected combinators (map, andThen, orElse) per the Expected idioms.
  • Named arguments / fluent builders per the repo style; move-only RAII structs use @disable this(this); refcounted handles incref on copy.
  • Version gating: the linked Nix version is probed at build time (from pkg-config, via the Nix flake) and surfaced as a D version identifier (e.g. -version=Nix_2_33); version-specific declarations are guarded with version (Nix_2_33) { … }, analogous to Rust's nix_at_least cfg. Reuse sparkles:versions SemVer for any version comparison logic.

13. Example application ​

apps/nix-eval — a CLI demonstrating the library end to end:

nix-eval '1 + 2'                     # → 3
nix-eval '[ 1 2 3 ]'                 # → [ 1, 2, 3 ]
nix-eval '{ x = 1; y = "hi"; }'      # → { x = 1; y = "hi"; }
nix-eval --flake 'path:.#someAttr'   # locks the flake and evaluates an output

It opens a NixSession, evaluates the argument, and recursively renders the resulting Value by dispatching on ValueType (int/float/bool/string/path/ null/list/attrs), exercising requireList/attrNames/requireAttr. The recursive renderer is the centerpiece of the demo and doubles as an integration test of the extraction API.

14. Non-goals (initial release) ​

  • Multi-threaded evaluation / a real Boehm-GC thread guard (needs binding <gc/gc.h>; §6.3).
  • Building Nix functions from D delegates (PrimOps; §9.6).
  • External values (nix_api_external.h vtable), XML/JSON external printers.
  • A store/eval cache layer (devenv-style SQLite memoization).
  • pURL/SBOM-style higher abstractions — out of scope for a binding library.

→ PLAN.md — delivery milestones and workflow orchestration → Integrating C Libraries (ImportC) — the binding workflow → Expected Error Handling Idioms — NixResult patterns