CX · Reference guideBindings
PlaygroundDownloadsAboutv0.18.0-pre.1

Bindings

We ship libcx as a V library; every other host language reaches it through a binding that wraps the compiled C ABI. There are two layers per binding: Layer 1 (the conformance contract — 16 canonical methods, byte-identical semantics across hosts) and Layer 2 (opt-in host-idiomatic sugar that desugars to Layer 1). We ship four bindings at Tier 1 — cx-binding-v, cx-binding-python, cx-binding-go and cx-binding-rust; six other bindings (TypeScript, Java, C#, Ruby, Kotlin, Swift) are archived under lang/_archived/ pending restoration — see migration. Normative spec: spec/03-approved/misc/bindings.md. C ABI surface: spec/03-approved/core/abi.md. Per-binding gate status: spec/03-approved/misc/parity-matrix.md.

The C ABI surface

libcx exposes a narrow, stable C ABI. Every binding sees the same symbol set; capability bits (cx_features()) tell a binding what optional features the loaded libcx supports. The full ABI surface — including signatures, error channels, and lifetime rules — is documented in spec/03-approved/core/abi.md; this section is the working summary for binding authors.

The ABI is string-in, string-out. Nearly every symbol takes NUL-terminated UTF-8 text and returns a heap-allocated UTF-8 string (or a length-framed binary buffer) that the caller releases with cx_free(). There is no document handle at this layer — no parse-once-then-navigate object, no node accessors, no per-document release call. The exceptions are the explicit handle families — streaming events (cx_events_*), table readers and writers (cx_table_reader_* / cx_table_writer_*), and the streaming event writer (cx_events_writer_*) — each with its own open / pull-or-emit / close lifecycle. The full symbol catalog is printed in c-abi-detail.

The Layer-1 Doc / Node surface (layer-1) is a binding-side construct built on this string ABI: each binding parses the document into its own host-language AST, answers Node accessors by walking that AST, and delegates selection, evaluation, and update to cx_code_eval. Identity — bytes() / hash() / equals() — rides cx_canonical, cx_hash, and cx_eq. Nothing about a Doc is retained inside libcx between calls.

Lifecycle — init, threads, features

Three functions form the binding-to-libcx handshake. They are mandatory for every Tier-1 binding and cheap-and-harmless for every other binding. Per spec/03-approved/core/abi.md §1.6.

  • cx_init() — idempotent. Every binding calls it once at module load. Enables host-thread registration in libcx's GC. Returns 0.
  • cx_thread_register() — first call from any host-spawned worker thread, before any other cx_* call on that thread. Idempotent (duplicate registration returns 0). Returns 0 on success or duplicate; -1 on real failure.
  • cx_thread_unregister() — when a worker thread exits, the binding unregisters to release the GC's stack-bottom record for that thread. Optional but recommended for long-lived processes with churn in their thread pool.
  • cx_features() — returns a NUL-terminated lowercase hex string encoding the capability bitmask. Bindings call this once at load and again any time a new-since-binding-built code path is taken. See cap-bit-negotiation for the negotiation protocol.
  • cx_version() / cx_abi_version() — return the libcx semver and the ABI semver respectively. Bindings include both in their version-report output.

Bit 26 of the cx_features() bitmask (parsed from the hex string) confirms the loaded libcx advertises the thread-init handshake. Bindings that need the handshake must refuse to load against a libcx that does not set this bit. Bindings that don't need it (V, single-threaded callers) may ignore the bit.

Canonical form and identity

Format conversion and identity are pure string functions. Every binding's Layer-1 Doc.bytes() / .hash() / .equals() maps directly onto them.

  • cx_to_cx(input, err_out) — parse CX text and return the lossless canonical text. The cx_to_* / cx_*_to_* family covers every input/output format pair (CX, XML, JSON, YAML, TOML, plus the AST and binary projections).
  • cx_canonical(input, err_out) — strict canonical text: presentation stripped, byte-identical output for any data-equivalent inputs.
  • cx_hash(input, err_out) — SHA-256 hex (64 chars) of the strict-canonical bytes.
  • cx_eq(a, b, err_out) — returns the string "1" iff strict-canonical(a) == strict-canonical(b), else "0".

All of these are stateless (class S in the abi.md concurrency classification): concurrent calls on disjoint inputs are safe without external synchronization, and concurrent calls on the same input buffer are also safe because the input is never mutated.

Evaluation — cx_code_eval family

The cx_code_eval* family is the single evaluator entry point for code, patterns, paths, and transforms. Callers write selection as a path expression or a [?for PATTERN] program and pass it through cx_code_eval.

  • cx_code_eval(input, program, output_target, err_out) — evaluate the program against the input document and return the result rendered to the target (text, cx, json, yaml, xml, csv, tsv). Cap bit 28.
  • cx_code_eval_caps(input, program, output_target, caps, err_out) — capability-aware variant (cap bit 38): caps is the deny-by-default host grant spec ("" = pure-only, "all" = full grant, "read,net=host:443" = scoped grants).
  • cx_code_eval_with_len(...) — explicit-length variant for payloads carrying embedded NUL bytes.
  • cx_code_eval_streaming(..., write_cb, user, err_out) — deliver rendered output to a callback in chunks. Streams incrementally only for streamable program shapes; cx_code_eval_streamable() reports in advance whether a (program, target) pair will stream or buffer.

Bindings expose Doc.eval(code) as their Layer-1 surface; Doc.select_all(cxpath) and Doc.select(cxpath) desugar to a [?for] program over the path, passed through cx_code_eval — the binding pays a small program-build cost, the C ABI stays narrow. See cxpath for the path vocabulary.

Structural access is binding-side

There are no node accessors at the C ABI. A binding obtains the document structure once — its own parser, or the cx_to_ast_bin binary AST — and answers Node.name() / .attr() / .attrs() / .children() / .body() / .kind() by walking that host-side tree. This keeps the ABI narrow and makes node access a zero-FFI operation after the initial parse.

Modify — pure-functional updates

There is no dedicated update symbol either. Layer-1 Doc.modify(focus, action) builds a [?modify] program from the focus path and the action clause (a CX program string such as [set 'Alice']) and evaluates it through cx_code_eval. The result is a new document; the input is unchanged.

Error channel

Every fallible ABI symbol takes an err_out parameter — a char** that on failure points to a NUL-terminated UTF-8 message in the wire format CXERnnnn:msg (e.g. CXER0100:parse: …). The binding extracts the code, wraps the message in its single host error type, and releases the buffer with cx_free().

code-range owner registry
CXER0001–CXER0009 Generic-core panic + core-internal failures spec/03-approved/core/code.md §9.4
CXER0100–CXER0299 CX language core (directive errors) spec/03-approved/core/code.md §9.4
CXERLEX-* Lexical-layer rejects (named suffix, non-numeric) spec/03-approved/formal/lexicon.ebnf
CXER1100 and up cx-stdlib packs — each pack owns its band spec/03-approved/process/governance.md §9.6
cx-err:E901–E911 Include resolution (parse-time data-parse codes) spec/03-approved/core/cxdm.md §11

Each binding carries the code verbatim in one error type — Python CxError(RuntimeError) with .code / .message, Go CxError, Rust Result<_, String> with the code in the message, V an error string. There is no per-family exception hierarchy, and lint findings are not errors at all (they are structured diagnostics from cx_lint). The namespace registry in spec/03-approved/process/governance.md §9.6 is the single source of truth; codes are stable and additions get fresh numbers.

Layer 1 — canonical method set

Layer 1 is the conformance contract. Every binding implements identical method names (case-adjusted to host conventions) with identical semantics. Drift between bindings on any Layer-1 method is a release-blocker (gate 28.6, Layer-1 parity). Normative spec: spec/03-approved/misc/bindings.md §2.

16 canonical methods. No more, no less, at this layer. The result of every Layer-1 method is byte-identical across V / Python / Go / Rust on the same input — a property the conformance harness validates by hashing each result and comparing per-binding.

method returns description implemented-over
parse(bytes) Doc Parse canonical CX bytes binding-side parser / AST
Doc.bytes() bytes Serialize Doc to canonical CX cx_canonical
Doc.hash() string SHA-256 of canonical bytes (hex) cx_hash
Doc.equals(other) bool Canonical-bytes equality cx_eq / cx_hash
Doc.eval(code) string Evaluate CX code against doc cx_code_eval
Doc.select_all(cxpath) [Node] CXPath path-value (all matches) cx_code_eval ([?for] program)
Doc.select(cxpath) Node? First match of select_all cx_code_eval ([?for] program)
Doc.modify(focus, action) Doc Pure-functional update cx_code_eval ([?modify] program)
Doc.find_all(name) [Node] Name-only convenience binding-side AST walk
Doc.root() Node Root element binding-side AST walk
Node.name() string Element name binding-side AST walk
Node.attr(name) Value? Attribute value binding-side AST walk
Node.attrs() Map All attributes (preserves order) binding-side AST walk
Node.children() [Node] Direct children binding-side AST walk
Node.body() Value Element body value binding-side AST walk
Node.kind() string Item-kind tag binding-side AST walk

CXPath is the selector vocabulary across every Layer-1 selection method. The path string is parsed and evaluated by libcx, not by the binding — that keeps semantics identical across hosts and lets the evaluator's program cache work cross-binding. At the binding surface a CXPath is a plain string in every language — there is no dedicated path type; the string is passed through to the evaluator verbatim.

Per-method semantics — selected highlights:

  • parse(bytes) — accepts CX text and produces a Doc (the binding's own parsed AST plus the retained source bytes).
  • Doc.hash() — always returns the strict-canonical SHA-256 (per canonical-strict) via cx_hash. There is no lossless-canonical hash method; the lossless form is a formatting surface (cx_fmt), not an identity surface.
  • Doc.eval(code) — takes a CX program as a string (a path expression, a [?for ...] comprehension, any program) and returns the rendered result string from cx_code_eval.
  • Doc.select_all(cxpath) — returns a host-native sequence of Nodes. Empty result is the host's empty sequence (Python [], Rust Vec::new(), Go nil slice, V []Node{}).
  • Doc.select(cxpath) — returns the first Node or the host's "no value" form (Python None, Rust Option::None, Go nil pointer, V ?Node).
  • Doc.modify(focus, action) — always returns a new Doc; the receiver is unchanged. The action is a CX program string (e.g. [set 'Alice']). If focus matches nothing, returns an unchanged copy (not an error).
  • Doc.find_all(name) — name-only convenience walk over the binding-side AST; equivalent result to selecting //name.
  • Node.attr(name) — returns the host's "absent" form when the attribute is missing (NOT an error). Inspect Node.attrs() to distinguish "absent" from "present-with-null-value".
  • Node.body() — for scalar bodies, returns the typed scalar (int, string, etc.). For mixed-content bodies, returns a host sequence of scalars + Nodes.
  • Node.kind() — reserved for the item-kind taxonomy (element / scalar kinds / collections). The full discrimination is not implemented yet: the current bindings return 'element' for the element Nodes the selection surface produces (the Rust binding hardcodes exactly that), and no binding ships a typed kind enum.
      # Python — Layer 1
         from cxlib import Doc
         doc = Doc.parse(open("users.cx", "rb").read())
         emails = doc.select_all("//user[= $_@active true]/@email")
         new_doc = doc.modify("//user[= $_@id 1]/@name", "[set 'Alice']")
         assert new_doc.hash() != doc.hash()  # different content
         assert Doc.parse(doc.bytes()).hash() == doc.hash()  # round-trip
    
      // Go — Layer 1 (PascalCase per Go style; semantics identical)
         doc, _ := cxlib.ParseDoc(data)
         emails, _ := doc.SelectAll("//user[= $_@active true]/@email")
         newDoc, _ := doc.Modify("//user[= $_@id 1]/@name", "[set 'Alice']")
         h1, _ := newDoc.Hash()
         h2, _ := doc.Hash()
         if h1 == h2 { panic("modify was no-op") }
    
      // Rust — Layer 1
         let doc = Doc::parse(&data)?;
         let emails = doc.select_all("//user[= $_@active true]/@email")?;
         let new_doc = doc.modify("//user[= $_@id 1]/@name", "[set 'Alice']")?;
         assert_ne!(new_doc.hash()?, doc.hash()?);
    
      // V — Layer 1 (native — no FFI wrapper)
         doc := cx.parse_doc(data)!
         emails := doc.select_all('//user[= $_@active true]/@email')!
         new_doc := doc.modify('//user[= $_@id 1]/@name', "[set 'Alice']")!
         assert new_doc.hash()! != doc.hash()!
    

Byte-identical across bindings (gate 28.6). For any input bytes B and code C, the conformance suite asserts V.Doc.parse(B).eval(C).bytes() == Python.… == Go.… == Rust.…. A binding that drifts on a single byte of output blocks the release. The matrix lives in spec/03-approved/misc/parity-matrix.md.

Layer 2 — host idiom packs

Layer 2 is opt-in sugar. Importing it pulls in host-idiomatic wrappers that compile to Layer-1 calls at the boundary. The compilation is deterministic — every Layer-2 expression has a single, documented Layer-1 desugaring. Spec: spec/03-approved/misc/bindings.md §3.

Why two layers? Two audiences. CX-native developers want CX vocabulary in every host language — they get Layer 1 and never touch Layer 2. Host-native developers want list comprehensions, filter chains, iterator combinators, derive macros — they get Layer 2 and the Layer-1 surface stays available underneath. Either audience can mix the two in the same module.

Python — cxlib.idioms

cxlib.idioms ships a Doc subclass with Pythonic dunders over Layer 1: __getitem__ with a CXPath string (→ select_all), __setitem__ that desugars to modify + [set …], division iteration (doc / "//path"), __contains__, clone(), and explain(expr) returning the Layer-1 equivalent of any Layer-2 expression.

        from cxlib.idioms import Doc, explain   # opt-in

           doc = Doc.parse(open("users.cx", "rb").read())

           # Subscript with CXPath string
           active = doc["//user[= $_@active true]"]
           # ≡ doc.select_all("//user[= $_@active true]")

           # Subscript-assignment desugars to modify + [set …]
           new_doc = doc.clone()
           new_doc["//user[= $_@id 1]/@name"] = "Alice"
           # ≡ new_doc.modify("//user[= $_@id 1]/@name", '[set "Alice"]')

           # Division iterator over a path
           for email in doc / "//user/@email":
               print(email)
           # ≡ for email in doc.select_all("//user/@email"):

           # Explain — surface the Layer-1 desugaring
           print(explain(doc / "//user"))
           # -> doc.select_all("//user")
      

List comprehensions directly over the Doc (and typed projections from a schema) are not built yet — the comprehension path needs CXPath-from-AST inference at the host level and is deferred to a later phase per the module's own docstring. The subscript / division pair covers the common path today.

Go — Layer-2 in package cxlib

The Go Layer-2 surface lives in the same cxlib package (there is no separate idioms import path): a chainable Filter / Where / Get builder that folds into one Layer-1 SelectAll call, Set / Delete / Rename action builders for Modify, MustSelectAll / MustSelect panic-on-error variants, and Explain(op, args...) returning the Layer-1 desugaring.

        import "github.com/cx-home/cx/lang/go/cxlib"

           doc, _ := cxlib.ParseDoc(data)

           // Builder chain → CXPath string under the hood
           emails, _ := doc.Filter("user").Where("= $_@active true").Get("/@email")
           // ≡ doc.SelectAll("//user[= $_@active true]/@email")

           // Action builders — each produces a CX program string
           action, _ := cxlib.Set("Alice")      // "[set \"Alice\"]", error
           newDoc, _ := doc.Modify("//user[= $_@id 1]/@name", action)
           _, _ = doc.Modify("//user[= $_@banned true]", cxlib.Delete())

           // Explain
           s, _ := cxlib.Explain("filter", "user", "= $_@active true")
           fmt.Println(s, len(emails), newDoc.Bytes() != nil)
      

There are no struct-tag projections and no Project[T] generic — typed materialization into host structs is not built. Note the two-value Set return: it validates the value and hands back (program string, error); the other builders are infallible and return the string directly.

Rust — cxlib::idioms

cxlib::idioms ships the DocExt extension trait — select_iter (iterator form of select_all), find_iter (name-only, no CXPath parse), and diagram_with(format) — plus explain(op, arg) returning the Layer-1 desugaring.

        use cxlib::code::Doc;
           use cxlib::idioms::{DocExt, explain};

           let doc = Doc::parse(&data)?;

           // Iterator over selection results
           let emails: Vec<_> = doc
               .select_iter("//user[= $_@active true]/@email")?
               .collect();

           // Name-only iteration — no CXPath parse
           let users: Vec<_> = doc.find_iter("user").collect();

           // Explain — the Layer-1 desugaring
           assert_eq!(explain("select_iter", "//user"),
                      r#"doc.select_all("//user")?.into_iter()"#);
      

There is no #[derive(CxData)], no cx! macro, no collect::<T>() typed projection, and no proc-macro crate — those surfaces are unbuilt. The idioms layer is the iterator/explain trio above; everything else is Layer 1.

V — Layer 1 is already idiomatic

V is the native reference. There is no separate Layer 2 wrapper because the Layer-1 surface IS V's idiomatic API — V naming conventions, V error semantics (!/?), V collection types. Adding a sugar layer would only duplicate what's already there.

If you find yourself wanting "Layer 2" in V, you are describing a feature the V binding should expose at Layer 1 in the first place. File an issue against lang/v/native/.

Layer-2 idiom packs are not API-conformance-tested. Different bindings have different idioms by design — Python subscripts vs Go builder chains vs Rust iterators. The conformance harness does test the Layer-1 desugaring of every documented Layer-2 expression — cx.explain(expr) returns the Layer-1 equivalent and is used both in fixtures and in LSP hovers. A Layer-2 method that does not desugar deterministically to Layer 1 is rejected on review.

Type mapping per binding

Every CX scalar kind maps to a specific host type in each binding. The mapping is mandatory at Layer 1 (gate 28.4, type-mapping conformance); a binding that deviates blocks release. Per spec/03-approved/misc/bindings.md §4.

Scalar kinds. The eleven CX atomic kinds (per scalars) plus sized variants flow to host types as follows. The leftmost column lists the CX type annotation (the :: glue); each subsequent column gives the host's primary representation. Normative source: spec/03-approved/misc/type-mapping.md.

cx-type v python go rust
::int i64 int int64 i64
::i8 i8 int int8 i8
::i16 i16 int int16 i16
::i32 i32 int int32 i32
::i64 i64 int int64 i64
::u8 u8 int uint8 u8
::u16 u16 int uint16 u16
::u32 u32 int uint32 u32
::u64 u64 int uint64 u64
::float f64 float float64 f64
::f32 f32 float float32 f32
::f64 f64 float float64 f64
::decimal Decimal (custom) decimal.Decimal apd.Decimal (exact decimal — never big.Float) bigdecimal::BigDecimal (not rust_decimal)
::bigint big.Integer int *big.Int num_bigint::BigInt
::string string str string String / &str
::bool bool bool bool bool
::date time.Time (date-only) datetime.date time.Time chrono::NaiveDate
::datetime time.Time datetime.datetime (aware) time.Time chrono::DateTime<FixedOffset>
::bytes []u8 bytes []byte Vec<u8>
atom (:name) cx.Atom cxlib.Atom cxlib.Atom cxlib::Atom
null json2.Null None nil Option::None

Container kinds. Sequences, arrays, and maps (see collections) map to host containers preserving the container-vs-atom distinction (cxdm §2.0).

cx-container v python go rust
sequence []Value list[Value] []Value Vec<Value>
array Array list (nested) []interface{} Array (Vec<Value> newtype)
map map[ScalarKey]Value dict[ScalarKey, Value] map[interface{}]Value HashMap<ScalarKey, Value>
table Table cxlib.Table cxlib.Table cxlib::Table
element Element Element Element Element

Overflow and edge cases. Host languages have different numeric ranges than CX. Two rules cover the boundary: an unsized integer that exceeds i64 auto-promotes to bigint (never an error), and a value ascribed a sized type it does not fit is rejected at parse time with CXERLEX-RANGE — never silently truncated, never silently widened.

  • Python int is arbitrary-precision. A Python int outside [-2^63, 2^63-1] flowing into CX becomes a bigint on the wire — bindings receive bigint, never an error (the decode rule in data-bin §3.4.1).
  • Sized ascriptions are checked at the lexical layer. A literal carrying a ::i8…::u64 annotation whose value is out of range for that width is a CXERLEX-RANGE reject (grammar [55] / lexicon [L25d]) — the document never parses.
  • Go and Rust have explicit sized types so the host-to-CX direction is unambiguous. CX-to-host widening (e.g. CX ::i32 to host int64) is silent and lossless; a narrowing conversion is the host program's own responsibility — the binding hands over the declared width.
  • Date / datetime precision. CX preserves nanosecond precision. Python datetime is microsecond — flowing to Python drops sub-microsecond digits. Round-tripping a Python datetime → CX → host preserves microseconds exactly; CX ::datetime → Python → CX may lose precision.
  • Bytes and strings. CX ::string is always valid UTF-8; CX ::bytes is opaque. Host byte sequences that are invalid UTF-8 cannot become a CX string — use ::bytes instead.
  • Null vs absent. CX null is a scalar value (a present-but-null attribute). Host None / nil / Option::None is the absent-value representation. The binding distinguishes via the Layer-1 surface: an attribute set to null returns the host's null-value; an absent attribute returns the host's absence-form ONLY when called via Node.attr(name) — which conflates them. To distinguish, inspect Node.attrs().

Type annotation preservation. Sized variants (::i32, ::u16, ::f32, etc.) round-trip through ast-bin and data-bin with their type identifier preserved. Bindings translate to the host's native numeric type on read and preserve the annotation on write. A Python int that enters libcx as ::int will not silently become ::i32 even if it fits; the annotation is sticky.

Thread-safety and concurrency model

CX documents are immutable values. Sharing a parsed Doc across threads is safe by construction — there is no shared mutable state to protect. The thread-safety story for each binding is mostly about: (a) registering host-spawned threads with libcx's GC and (b) wrapping the binding's own handle types to match the host's concurrency model.

Document immutability

A parsed Doc is logically immutable. Doc.modify() returns a new Doc; there is no in-place mutation API at any layer. This means a single Doc handle can be shared across N threads with no synchronization — every thread sees the same bytes, every thread's hash() returns the same digest, every thread's select_all returns the same Nodes.

Bindings expose this in their type system where they can. Rust: Doc: Send + Sync is automatic from the underlying types. Go: Doc is a value type wrapping a libcx handle — safe to pass between goroutines. Python: the GIL serializes access in-process; multi-process workers pickle the Doc's canonical bytes and reparse. V: Docs are shareable across go blocks without shared / lock keywords.

Layer-1 method reentrancy

All 16 Layer-1 methods are reentrant. Calling doc.select_all(p1) on thread A while doc.eval(c1) runs on thread B is safe; neither call mutates anything the other reads. The C ABI symbols backing them are classified (S) stateless in spec/03-approved/core/abi.md §1.4 — concurrent calls on disjoint inputs are safe without external synchronization, and concurrent calls on the same input buffer are also safe because the buffer is read-only.

Modify is also reentrant. doc.modify(...) does not lock the receiver. Two threads modifying the same Doc with different focuses each produce their own independent new Doc; the original is unchanged. If both new Docs need to be combined, do so explicitly with a second modify.

Thread registration (cap bit 26)

Host-spawned OS threads — Python workers via threading.Thread, Rust std::thread::spawn, Go via cgo's per-goroutine OS thread, V via go fn{} outside the V scheduler — must call cx_thread_register() before any other cx_* call. This registers the thread's stack-bottom with libcx's GC.

What each binding actually does differs — know your binding. Rust: every FFI chokepoint calls ensure_thread() before the C call, and thread exit unregisters — fully automatic. Go: each cgo call runs on a locked OS thread (LockOSThread) and the internal cxThread() guard registers it — also automatic. Python does NOT register: the binding assumes the GIL serializes access and that libcx is called from a single thread — a threading.Thread that calls into libcx is unprotected; keep libcx calls on one thread in Python. V: threads spawned by the V scheduler are already known to the GC.

Skipping registration is the single most common crash-on-thread-spawn bug in user code that bypasses the Layer-1 surface and calls the C ABI directly. The crash signature is a segfault inside GC_mark_from during the GC's next collection. If you see that backtrace, the first thing to check is whether your thread called cx_thread_register().

GC interactions per binding

The string-in/string-out ABI keeps the memory models almost completely decoupled: every string libcx returns is copied into a host value at the call boundary and released immediately with cx_free(), so no host object ever points into libcx's Boehm heap between calls.

  • V — Boehm GC throughout. V's runtime is also Boehm-GC. A Doc is a normal V value; no bridging needed. V is the simplest case.
  • Python — plain refcounted objects. A Doc is an ordinary Python object (the binding-side AST plus the source bytes). Python's refcounting reclaims it like any other value; libcx holds nothing on its behalf.
  • Go — plain values. A Doc is a Go struct owned entirely by Go's GC. Each cgo call copies its result out of libcx and frees the C string before returning — no finalizers, no pinning.
  • Rust — plain owned values. A Doc owns its parsed AST and source bytes; dropping it is ordinary Rust ownership. The FFI layer copies every returned string and calls cx_free() inside the wrapper.

GC_DONT_GC workaround — the TypeScript binding (Node.js + V8) hit a libgc GC_mark_from crash that necessitated setting GC_DONT_GC=1, effectively disabling libgc's collector for the process lifetime (libcx allocations leak; the process is short-lived so it does not matter). This is a per-host workaround, not a model — Tier-1 bindings (V/Python/Go/Rust) do NOT need it. The TypeScript binding is archived under lang/_archived/typescript/ per migration; restoration depends on root-cause fix upstream.

Capability bit negotiation

Bindings ship at a specific libcx ABI version but may be loaded against an older or newer libcx. The capability bitmask returned by cx_features() is the runtime contract: the binding declares what it needs, libcx declares what it supports, and the binding either degrades, errors, or proceeds. Per spec/03-approved/core/abi.md §3.

The bitmask is a NUL-terminated lowercase hex string encoding a 64-bit mask. Each bit covers one optional feature. New features get new bits; bits are never repurposed — a retired feature leaves its bit permanently RESERVED (bits 8, 13, and 17 are examples). A binding parses the hex string to an integer once and tests individual bits against it.

Negotiation protocol:

  • Binding load. Binding calls cx_features() once, caches the result in a process-global variable, and compares against its required-bits mask. If a required bit is missing, the binding raises a load-time error identifying which feature is unavailable.
  • Per-call check. For entry points that may be version-gated (e.g. the chunked-table reader requires bit 21), the binding's method checks the cached bitmask before calling the C ABI and returns a host error early when the bit is clear.
  • Forward-compat. A newer libcx with bits set that the binding doesn't know about is fine — the binding ignores unknown bits. A newer feature is accessible only when the binding is itself updated to call its entry point.
  • Backward-compat. An older libcx missing bits the binding requires is NOT fine — the binding refuses to load. There is no fallback path; users must upgrade libcx.

Selected capability bits (full registry in spec/03-approved/core/abi.md §3):

bit mask feature
0 0x1 ABI core conversion symbols (always set)
1 0x2 Binary AST (cx_to_ast_bin; always set)
5 0x20 data_bin one-shot loaders / dumpers
15 0x8000 Logfmt mode (top-level bare attributes)
16 0x10000 Numeric underscores (1_000_000)
17 0x20000 RESERVED — superseded by bit 23 (query bit 23 for Arrow)
21 0x200000 Chunked-table format + streaming table reader/writer ABI
23 0x800000 Apache Arrow C-Data interop (libcx_arrow)
24 0x1000000 Schema-driven data-bin encoding
26 0x4000000 Thread-init handshake (cx_init / cx_thread_register)
28 0x10000000 cx_code_eval family — unified evaluator entry
29 0x20000000 Collection literals — array / map / sequence Item kinds
30 0x40000000 First-class function values ([?fn])
31 0x80000000 Code-to-diagram (cx_code_diagram)
32 0x100000000 Code tree projection (cx_code_tree)
      import cxlib as cx

         # cx.features() parses the hex string to an int bitmask.
         caps = cx.features()

         if not (caps & (1 << 23)):
             # No Arrow C-Data interop in this libcx — stay on the
             # text/data-bin surfaces instead of the Arrow bridge.
             use_arrow = False
         else:
             use_arrow = True
    

FFI lifetime and memory ownership

The string ABI keeps ownership simple: libcx allocates a result string on its own heap, the binding copies it into a host value, and the wrapper calls cx_free() before returning. A Doc is an ordinary host object — the binding-side AST plus the retained source bytes — owned entirely by the host's memory model. Only the streaming handle families carry an explicit lifecycle.

V — Boehm GC end-to-end

V uses Boehm GC at the language level; the V binding imports the core directly, so a Doc is a normal V value that becomes collectable when no V variable references it. No explicit close exists or is needed.

Python — plain refcounted objects

A Python Doc is an ordinary object; Python's refcounting reclaims it deterministically when the last reference goes out of scope. There is no close(), no __del__, and no context-manager protocol on Doc — there is nothing to release, because every libcx string was copied and freed at the call boundary.

The classes that DO carry a close() and context-manager protocol are the handle-backed ones: Stream, TableReader, TableWriter, and EventWriter. Use with on those.

Go — plain values

A Go Doc is a value type owned by Go's GC; there is no Close(), no runtime.SetFinalizer, and no closed-Doc error state. Each cgo call copies its result out of the C string and frees it before returning. The handle-backed stream and table types are the only ones with explicit close methods.

Rust — plain owned values

A Rust Doc owns its parsed AST and source bytes; dropping it is ordinary ownership, with no custom Drop impl needed. The FFI layer copies every returned string and calls cx_free() inside the wrapper, so no Rust value ever points into libcx's heap.

Nodes are plain values too

Nodes returned from a Doc's selection methods are host values built from the binding-side AST. They do not borrow libcx memory, and there is no handle-after-free failure mode — a Node outliving its Doc is just a host object outliving another host object.

Important — Nodes are NOT mutable shared state. Two Nodes derived from the same Doc do not interfere with each other. The Doc is logically immutable (modify produces a NEW Doc; the original is unchanged). A Node is a read-only window onto a portion of the Doc.

Explicit release — handles only

The only explicit lifecycles at the ABI are the handle families: cx_events_close for streaming events, cx_table_reader_close / cx_table_writer_close for chunked tables, and cx_events_writer_close for the streaming writer. Bindings surface these as close() methods (context managers in Python); everything else is ordinary host memory.

For document-streaming workloads, prefer the streaming surfaces (see streaming-data) over parsing whole documents; streaming bounds resident memory to one chunk regardless of total input size.

Schema-driven codegen

When a CX schema (.cxs, see schema) is available, every binding could generate typed Layer-2 projections from it. This is planned follow-up work; the spec slot is reserved in spec/03-approved/misc/bindings.md §7, and no CLI surface for it exists yet — not even a stub.

What a schema states today is what the validator grades — a missing required attribute is a violation with a code, a path and a message, replayed here from the corpus — and a generated projection would be a projection of the same schema:

prog.cx

        [$cx:validate "[person name=ann]" "[schema of=person version='0.8'] [type person [attr age :type=int :required=true]]"]
      
        $ cx prog.cx
([violation code=S002 path='' message="missing required attribute 'age' on <person>"])
      

Fixture cx-136-validate-violation-item in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.

Planned per-target output (future revision):

  • Python — a generated module with a User dataclass, type-checked accessors, and a round-trip users_from_doc(doc) -> list[User] / doc_from_users(users) -> Doc pair.
  • Go — generated structs and methods, go fmt-clean.
  • Rust — generated structs and typed materialization helpers, rustfmt-clean.
  • V — no codegen target; V binding's Layer-1 surface already gives typed access to schema fields when the schema is loaded at runtime.

Status — the schema validator is available but codegen does not exist yet, even as a stub. There is no codegen verb on the CLI: the shipped cx schema family is cx schema infer (synthesize a .cxs from a corpus) and cx schema export --to=json-schema (project a schema to JSON Schema 2020-12). Typed projections from a schema are design work no binding has merged.

Install + hello world per binding

The minimum-viable per-binding workflow: install, parse a tiny document, compute a hash, evaluate one expression. Every binding's hello-world produces byte-identical hash output; that is the simplest cross-binding parity check.

V (native reference)

V is the native language of libcx. The binding lives in lang/v/native/. Importing it is a normal V module import; no FFI involved. The pure-V package is published as module cx — install with v install --git https://github.com/cx-home/cx-v.

        // v install --git https://github.com/cx-home/cx-v
           import cx

           fn main() {
             doc := cx.parse_doc('[hello world=true]')!
             println(doc.hash()!)
             // -> 8f3d... (64-hex-char SHA-256)
             v := doc.eval('//hello/@world')!
             println(v) // -> true
           }
      

Python

pip install cxlib installs the Python binding (when published — pre-publish use pip install -e lang/python/). Requires Python 3.10+.

        # pip install cxlib
           from cxlib import Doc

           doc = Doc.parse(b"[hello world=true]")
           print(doc.hash())
           # -> 8f3d... (matches V output exactly)
           print(doc.eval("//hello/@world"))
           # -> true
      

Go

go get github.com/cx-home/cx/lang/go installs the Go module; the package is github.com/cx-home/cx/lang/go/cxlib. Requires Go 1.22+.

        // go get github.com/cx-home/cx/lang/go
           package main

           import (
             "fmt"
             "github.com/cx-home/cx/lang/go/cxlib"
           )

           func main() {
             doc, _ := cxlib.ParseDoc([]byte("[hello world=true]"))
             h, _ := doc.Hash()
             fmt.Println(h)
             // -> 8f3d... (matches V + Python)
             v, _ := doc.Eval("//hello/@world")
             fmt.Println(v) // -> true
           }
      

Rust

cargo add cxlib adds the Rust binding (when published — pre-publish use a path dependency to lang/rust/cxlib/). Requires Rust 1.75+.

        // Cargo.toml: cxlib = "..."   [; see crates.io for the current version ]
           use cxlib::code::Doc;

           fn main() -> Result<(), String> {
             let doc = Doc::parse(b"[hello world=true]")?;
             println!("{}", doc.hash()?);
             // -> 8f3d... (matches V + Python + Go)
             let v = doc.eval("//hello/@world")?;
             println!("{}", v); // -> true
             Ok(())
           }
      

Verify cross-binding parity

The fastest cross-binding sanity check is to hash the same input from the CLI and from each binding and confirm all five outputs match:

        $ echo '[hello world=true]' | cx hash
           8f3d2a1b...
           $ v run hello_v.v        # -> 8f3d2a1b...
           $ python hello.py        # -> 8f3d2a1b...
           $ go run hello.go        # -> 8f3d2a1b...
           $ cargo run --bin hello  # -> 8f3d2a1b...
      

The ABI vectors of the front door's corpus are the contract each binding grades itself against; the one below is replayed by this guide's cx when the page is built:

input.cx

          # a note about the user
[user id=1 name="Alice"]
        
          $ cx --from=cx --to=cx input.cx
# a note about the user
[user id=1 name=Alice]
        

Fixture abi-001-fmt-lossless-retains-comment in deps/cx-core-code/conformance/abi.cxd — replayed against this cx when the page was built.

If any binding produces a different hash, that binding has drifted from the canonical-bytes contract — file against spec/03-approved/misc/parity-matrix.md and the conformance harness will catch it on next CI run.

Quickstarts (V / Python / Go / Rust)

Beyond hello-world, each Tier-1 binding has an idiomatic pattern for the most common CX workloads: parse, select, iterate, modify, serialize. These quickstarts show the Layer-2 idiomatic path; the Layer-1 fallback is always available.

V (native reference)

V is the native language of libcx. The Layer-1 surface IS V's idiomatic API; errors bubble via ! / ? propagation. Concurrency uses go fn(){} with channels; Docs are safely shareable between goroutines without shared / lock keywords because they are immutable values.

        import cx
           import os

           fn main() {
             data := os.read_file('users.cx')!
             doc := cx.parse_doc(data)!
             for user in doc.select_all('//user[= $_@active true]')! {
               println(user.attr('email') or { '<no email>' })
             }
             new_doc := doc.modify('//user[= $_@id 1]/@name',
                                   "[set 'Alice']")!
             os.write_file('users-updated.cx', new_doc.bytes())!
             println(new_doc.hash()!)
           }
      

Python

Python uses the cxlib package; the ctypes FFI bridge is in lang/python/cxlib/cx.py. Layer 2 sugar lives in cxlib.idioms. Requires Python 3.10+. Typed projections from a .cxs schema are future work (see schema-codegen).

        from cxlib.idioms import Doc    # opt-in sugar

           with open('users.cx', 'rb') as f:
               doc = Doc.parse(f.read())

           # Division-iterator over a path
           emails = [n for n in doc / "//user[= $_@active true]/@email"]

           # Subscript-modify
           new_doc = doc.clone()
           new_doc["//user[= $_@id 1]/@name"] = 'Alice'

           with open('users-updated.cx', 'wb') as f:
               f.write(new_doc.bytes())
           print(new_doc.hash())
      

Go

Go uses cgo to call the C ABI. The module is github.com/cx-home/cx/lang/go; Layer 1 and the Layer-2 builders share the one cxlib package. Requires Go 1.22+. The cgo bridge finds libcx through the repository's Makefile targets; LIBCX_PATH / LIBCX_LIB_DIR point it at a specific build when needed. Docs are safe to share across goroutines — each cgo call runs on a locked, registered OS thread.

        package main

           import (
             "fmt"; "os"
             "github.com/cx-home/cx/lang/go/cxlib"
           )

           func main() {
             data, _ := os.ReadFile("users.cx")
             doc, _ := cxlib.ParseDoc(data)
             emails, _ := doc.Filter("user").Where("= $_@active true").Get("/@email")
             for _, e := range emails { fmt.Println(e.Body()) }
             action, _ := cxlib.Set("Alice")
             newDoc, _ := doc.Modify("//user[= $_@id 1]/@name", action)
             h, _ := newDoc.Hash()
             fmt.Println(h)
           }
      

Rust

Rust wraps the C ABI through hand-written extern "C" declarations (no bindgen); the crate lives at lang/rust/cxlib/. Layer 2 in cxlib::idioms. Requires Rust 1.75+. A Doc is a plain owned value; share it across threads with Arc<Doc> — every FFI chokepoint registers its calling thread automatically.

        use cxlib::code::Doc;
           use cxlib::idioms::DocExt;
           use std::fs;

           fn main() -> Result<(), String> {
             let data = fs::read("users.cx").map_err(|e| e.to_string())?;
             let doc = Doc::parse(&data)?;
             for email in doc.select_iter("//user[= $_@active true]/@email")? {
               println!("{}", email.name());
             }
             let new_doc = doc.modify("//user[= $_@id 1]/@name", "[set 'Alice']")?;
             println!("{}", new_doc.hash()?);

             // Concurrent reads — Doc is plain owned data
             use std::{thread, sync::Arc};
             let shared = Arc::new(doc);
             let handles: Vec<_> = (0..4).map(|_| {
               let d = Arc::clone(&shared);
               thread::spawn(move || d.select_all("//user").unwrap().len())
             }).collect();
             for h in handles { println!("{}", h.join().unwrap()); }
             Ok(())
           }
      

Per-binding reference snippets

Compact reference for each Tier-1 binding: install command, the 16-method Layer-1 surface in idiomatic form, the atom scalar kind, schema validation, and the type-mapping table. The full normative contract is spec/03-approved/misc/bindings.md §2.1; the C ABI is spec/03-approved/core/abi.md.

C ABI — symbol catalog

The libcx C ABI partitions into nine families: format conversion, canonical+identity, binary forms (ast_bin / data_bin), evaluation (cx_code_eval*), diagram+tree, schema validation, identity and resolution (id / IDREF), streaming events + tables, and threading + memory. Capability bits gate each family; bindings query cx_features() at load time and degrade or refuse to load when a required bit is clear. Every char * returned by libcx must be released with cx_free — never free().

        /* Format conversion. */
           char *cx_to_cx        (const char *in, char **err);
           char *cx_to_xml       (const char *in, char **err);
           char *cx_to_json      (const char *in, char **err);
           char *cx_to_yaml      (const char *in, char **err);
           char *cx_to_toml      (const char *in, char **err);
           char *cx_to_md        (const char *in, char **err);
           char *cx_to_csv       (const char *in, char **err);
           char *cx_from_xml     (const char *in, char **err);
           /* …yaml / toml / md / csv variants… */

           /* Canonical + identity (cap bit 7). */
           char *cx_canonical(const char *in, char **err);
           char *cx_hash     (const char *in, char **err);
           int   cx_eq       (const char *a, const char *b, char **err);
           char *cx_diff     (const char *a, const char *b, const char *fmt, char **err);
           char *cx_lint     (const char *in, const char *fmt, const char *disabled, char **err);

           /* Binary forms. */
           char *cx_to_ast_bin    (const char *in, char **err);
           char *cx_ast_bin_to_cx (const char *ast_bin, char **err);
           char *cx_to_data_bin   (const char *in, char **err);
           char *cx_from_data_bin (const char *data_bin, char **err);

           /* Evaluation (cap bit 28; renamed from cx_program_eval). */
           char *cx_code_eval         (const char *input, const char *program,
                                       const char *output_target, char **err);
           char *cx_code_eval_with_len(const char *input,   size_t input_len,
                                       const char *program, size_t program_len,
                                       const char *output_target, char **err);
           char *cx_code_eval_streaming(const char *input,   size_t input_len,
                                        const char *program, size_t program_len,
                                        const char *output_target,
                                        cx_code_write_cb write_cb, void *user,
                                        char **err);

           /* Diagram + tree (cap bits 31 / 32). */
           char *cx_code_diagram (const char *source, size_t source_len,
                                  const char *format, size_t format_len);
           char *cx_code_tree    (const char *source, size_t source_len,
                                  size_t *out_len);
           char *cx_code_ast_json(const char *source, size_t source_len);

           /* Schema validation (cap bit 25). */
           char *cx_validate                (const char *doc, const char *schema, char **err);
           char *cx_validate_apply_defaults (const char *doc, const char *schema,
                                             char **modified_doc_out, char **err);

           /* Threading + memory (cap bit 26). */
           int  cx_init(void);
           int  cx_thread_register(void);
           int  cx_thread_unregister(void);
           void cx_free(char *p);

           /* Capability + version queries. */
           char *cx_features(void);     /* lowercase hex of 64-bit bitmask */
           char *cx_abi_version(void);  /* ABI semver, e.g. "2.0" */
           char *cx_version(void);      /* library semver (repo VERSION) */

           /* Include-root variants (Phase 3.6). */
           char *cx_to_cx_with_include_root      (const char *in, const char *root, char **err);
           char *cx_to_ast_bin_with_include_root (const char *in, const char *root, char **err);
           char *cx_to_data_bin_with_include_root(const char *in, const char *root, char **err);

           /* Wasm arena tuning (wasm build only). */
           int cx_wasm_set_arena_size(unsigned int bytes);
           int cx_wasm_reset(void);
      

v0.8.0 adds bits 31 (cx_code_diagram), 32 (cx_code_tree), 33 (atoms), 34 ([?def]), 35 ([?lib]), and 36 (PathNode wire format / ast_bin v8). Bit 28 (CX code evaluator) is unchanged from v0.7.6.

V binding — install, Layer 1, atom

V is the native reference implementation. libcx is written in V; the V binding under lang/v/native/ imports the cx package directly with zero FFI overhead. Every other Tier-1 binding wraps the same C ABI that the V core exports. The lang/v/cffi/ variant was archived to lang/_archived/v-cffi/; preserved as a potential FFI-overhead benchmark.

        v install --git https://github.com/cx-home/cx-v
      
        import cx

           // Doc construction + identity (Layer 1 — 16 methods).
           doc := cx.parse_doc(bytes) or { panic(err) }
           out := doc.bytes()
           h   := doc.hash()!
           eq  := doc.equals(other)!

           // Evaluation (wraps cx_code_eval).
           val := doc.eval(code) or { panic(err) }

           // CXPath selection.
           nodes := doc.select_all('//user[= $_@active true]/@email')!
           first := doc.select('//user[= $_@id 1]')!

           // Pure-functional update — the action is a CX program.
           new_doc := doc.modify('//user[= $_@id 1]/@name', "[set 'Alice']")!

           // Convenience + root.
           users := doc.find_all('user')
           root  := doc.root()?

           // Node accessors.
           name     := node.name()
           val_opt  := node.attr('email')
           attrs    := node.attrs()
           children := node.children()
           body     := node.body()
           kind     := node.kind()

           // Includes — explicit include root on the conversion surface.
           resolved := cx.to_cx_with_include_root(input, '/proj') or { panic(err) }

           // Atom scalar — type-strict, no string coercion.
           a := cx.atom('paid')!
           assert cx.is_atom(a)
           assert cx.atom_name(a)! == 'paid'
      

The V core exports cx_code_diagram (auto-detected erDiagram or flowchart Mermaid output) and cx_code_tree (JSON projection with source loc ranges for the playground tree pane). These exports power the wasm playground and are not wrapped by the FFI bindings; cap bit 31 (diagram) and bit 32 (tree) advertise availability.

Python binding — install, Layer 1, type map

The Python binding wraps the libcx C ABI through ctypes (lang/python/cxlib/cx.py). Layer 1 method names are snake_case per Python convention; semantics are identical to V / Go / Rust. Layer 2 idiomatic sugar — subscript-with-CXPath, division iteration — lives in the opt-in cxlib.idioms module and desugars deterministically to Layer 1 calls.

        pip install cxlib
           # Or from source (pre-publish):
           git clone https://github.com/cx-home/cx
           cd cx/lang/python
           pip install -e .
      
        import cxlib
           from cxlib import Doc

           # Doc construction + identity.
           doc = Doc.parse(open('users.cx', 'rb').read())
           out = doc.bytes()
           h   = doc.hash()
           eq  = doc.equals(other)

           # Evaluation (cx_code_eval).
           val = doc.eval(code)

           # CXPath.
           emails = doc.select_all('//user[= $_@active true]/@email')
           first  = doc.select('//user[= $_@id 1]')

           # Pure-functional update — the action is a CX program string.
           new_doc = doc.modify('//user[= $_@id 1]/@name', "[set 'Alice']")
           new_doc = doc.modify('//user[= $_@banned true]', '[delete]')

           # Node access.
           shop = Doc.parse(b'[shop name=Pepe [pizza name=Margherita price=12]]').root()
           print(shop.attr('name'))              # 'Pepe'
           print(shop.children()[0].attr('name')) # 'Margherita'

           # Atom — type-strict, no string coercion.
           a = cxlib.atom('paid')
           assert cxlib.is_atom(a)
           assert cxlib.atom_name(a) == 'paid'
           assert a != 'paid'

           # Streaming — stream() takes the CX text.
           with open('events.cx') as f:
               for event in cxlib.stream(f.read()):
                   if event.type == 'StartElement' and event.name == 'error':
                       print(event.attrs)

           # Schema validation.
           report = cxlib.validate(
               open('order.cx').read(),
               open('order.cxs').read(),
           )
      

Type mapping (CX → Python): int → int, bigint → int, decimal → decimal.Decimal, float / f32 / f64 → float, bool → bool, string → str, atom → cxlib.Atom, date → datetime.date, datetime → datetime.datetime, bytes → bytes, null → None.

Go binding — install, Layer 1, cgo

The Go binding wraps the libcx C ABI through cgo. Layer 1 method names use Go PascalCase per host convention; semantics are identical to V / Python / Rust. Each goroutine that calls into libcx gets a thread registration on first use; the binding manages the Boehm GC handshake at every FFI chokepoint, so calling code does not need to think about it. Stateless format conversions are safe to call concurrently on disjoint inputs.

        go get github.com/cx-home/cx/lang/go
      
        import "github.com/cx-home/cx/lang/go/cxlib"

           // Doc construction + identity.
           doc, err := cxlib.ParseDoc(input)
           out     := doc.Bytes()
           h, _    := doc.Hash()
           eq, _   := doc.Equals(other)

           // Evaluation (cx_code_eval).
           val, err := doc.Eval(code)

           // CXPath.
           emails, _ := doc.SelectAll("//user[= $_@active true]/@email")
           first, _  := doc.Select("//user[= $_@id 1]")

           // Pure-functional update — Set builds the action program.
           action, _ := cxlib.Set("Alice")
           newDoc, _ := doc.Modify("//user[= $_@id 1]/@name", action)

           // Tree access.
           tree, _ := cxlib.ParseDoc([]byte("[shop [pizza name=Margherita price=12]]"))
           pizza := tree.FindAll("pizza")[0]
           name  := pizza.Attr("name")
           price := pizza.Attr("price")

           // Atom — type-strict, no string coercion.
           a, _ := cxlib.Atom("paid")
           fmt.Println(cxlib.IsAtom(a))    // true
           fmt.Println(a.AtomName())       // "paid"

           // Schema validation — doc + schema as CX text.
           report, _ := cxlib.Validate(docText, schemaText)

           // Streaming — Stream returns the event slice.
           events, _ := cxlib.Stream(cxStr)
           for _, e := range events {
               if e.Type == "StartElement" && e.Name == "error" {
                   fmt.Println(e.Attrs)
               }
           }
      

Rust binding — install, Layer 1, threading

The Rust binding wraps the libcx C ABI through hand-written extern "C" declarations. Layer 1 method names are snake_case per Rust convention. The binding registers each calling thread with libcx via ensure_thread at every FFI chokepoint; cx_thread_register and cx_thread_unregister fire automatically, so manual setup is unnecessary. Stateless conversions can be called from any thread without coordination. Schema validate IS implemented for Rust — the 📋 in spec/03-approved/misc/parity-matrix.md §2 is a stale row awaiting its update.

        cargo add cxlib
      
        use cxlib::code::{parse, Doc, Node};

           // Doc construction + identity.
           let doc: Doc = parse(&input)?;
           let out: Vec<u8> = doc.bytes();
           let h: String    = doc.hash()?;
           let eq: bool     = doc.equals(&other)?;

           // Evaluation (cx_code_eval).
           let val = doc.eval(code)?;

           // CXPath.
           let emails: Vec<Node> = doc.select_all("//user[= $_@active true]/@email")?;
           let first: Option<Node> = doc.select("//user[= $_@id 1]")?;

           // Pure-functional update — the action is a CX program.
           let new_doc = doc.modify("//user[= $_@id 1]/@name", "[set 'Alice']")?;

           // Atom.
           let a = cxlib::Atom::new("paid")?;
           assert_eq!(a.name(), "paid");

           // Schema validation — two-arg, returns a ValidationReport.
           let report = cxlib::validate::validate(doc_text, schema_text)?;
      

Type mapping (CX → Rust): int → i64 (or sized variant if ::iN/::uN), bigint → num_bigint::BigInt, decimal → bigdecimal::BigDecimal (arbitrary precision — not rust_decimal, whose 96-bit form cannot represent the wire image), float → f64 (or f32 if sized), bool → bool, string → String, atom → cxlib::Atom, date → chrono::NaiveDate, datetime → chrono::DateTime, bytes → Vec<u8>.

ABI stability and versioning

The C ABI is the contract that lets every binding call into libcx without recompilation, so its evolution rules are worth stating plainly. The normative policy lives in spec/03-approved/process/governance.md; this is the practicing summary.

  • Semver discipline, pre-1.0. On the 0.x line the ABI may break at minor versions; every break is annotated in CHANGELOG.md with a migration note. From 1.0 the ABI breaks only at major versions, with a published migration guide.
  • Capability bits are the forward-compatibility mechanism. A binding examines the feature bitmask at load and enables, degrades, or refuses per feature — the negotiation flow and the current bit map are in the capability-bit subsection above. A bit, once assigned, keeps its meaning forever; features never reuse bits.
  • Newer libcx, older binding: works. The library exports a superset; unused symbols and unclaimed bits sit idle. This is the normal state after a library upgrade — bindings keep working without recompile.
  • Older libcx, newer binding: refuses or degrades, never limps. A missing required bit fails the load with a clear error; a missing optional bit disables that API surface, which then errors as \"feature unavailable\" when called — never a silent wrong answer.
  • Symbols are append-only. Frozen signatures stay frozen; a signature that must change arrives as a new suffixed symbol beside the old one, and removal is a major-version event.

Schema compatibility versions independently of the ABI — what may change within a schema line, and how breaking schema changes travel, is Ring 0 material: schema-evolution.

The WebAssembly build

libcx also builds to WebAssembly (scripts/wasm/ build_libcx_wasm.sh; artifacts land in dist/wasm/). The wasm build is a strict subset of the native C ABI — every exported symbol already exists in the native library — so tools written against the ABI work identically against wasm for the surface it exposes, through the same JS wrapper the playground uses (scripts/wasm/cxlib.js).

What wasm is for. Browser-side tooling — the playground, in-page validators and formatters, documentation that evaluates its own examples — and low-trust embedding, where the wasm sandbox is a hard isolation boundary: the embedded engine cannot reach the host filesystem or network at all, however the capability set is misconfigured. The playground and its delivery modes (single-threaded static hosting versus the locally-served build where [par] gets real threads) are covered with the rest of the tooling in tooling.

What wasm is not. The production target. Native deployments use the CLI, the language bindings, or libcx as a shared library — none of which carry the wasm build's event-loop instrumentation, bundle-size pressure, or single-thread default. Expect wasm to run at a substantial fraction of native speed and treat the playground as an explorable demonstration surface, not a benchmark.

A pure-JavaScript reimplementation of CX (no wasm) is a long-term option that is deliberately not scheduled: it would mean two implementations held in parity with the spec. The wasm path keeps a single source of truth — one engine, compiled twice.