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 othercx_*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. Thecx_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):capsis 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) viacx_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 fromcx_code_eval.Doc.select_all(cxpath)— returns a host-native sequence of Nodes. Empty result is the host's empty sequence (Python[], RustVec::new(), Gonilslice, V[]Node{}).Doc.select(cxpath)— returns the first Node or the host's "no value" form (PythonNone, RustOption::None, Gonilpointer, 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']). Iffocusmatches 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). InspectNode.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 abiginton 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…::u64annotation 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
::i32to hostint64) 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
datetimeis microsecond — flowing to Python drops sub-microsecond digits. Round-tripping a Pythondatetime→ CX → host preserves microseconds exactly; CX::datetime→ Python → CX may lose precision. - Bytes and strings. CX
::stringis always valid UTF-8; CX::bytesis opaque. Host byte sequences that are invalid UTF-8 cannot become a CX string — use::bytesinstead. - Null vs absent. CX
nullis a scalar value (a present-but-null attribute). HostNone/nil/Option::Noneis the absent-value representation. The binding distinguishes via the Layer-1 surface: an attribute set tonullreturns the host's null-value; an absent attribute returns the host's absence-form ONLY when called viaNode.attr(name)— which conflates them. To distinguish, inspectNode.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
Userdataclass, type-checked accessors, and a round-tripusers_from_doc(doc) -> list[User]/doc_from_users(users) -> Docpair. - 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.mdwith 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.