CX · Reference guideSurfaces and projections
PlaygroundDownloadsAboutv0.18.0-pre.1

Surfaces and projections

CX has one canonical surface (the bracket form) and projections for JSON, YAML, TOML, XML, Markdown, and CSV/TSV/PSV. Every projection is defined as a transformation of canonical CX. The normative rules live in spec/03-approved/core/canonical.md and spec/03-approved/core/conversions.md; this section is the developer-facing working summary.

CX — the canonical dialect

CX has two canonical forms — one for source-of-truth formatting (preserves comments, anchors, presentation), one for content-addressing (strips presentation, expands aliases, normalizes everything that doesn't change data). Per spec/03-approved/core/canonical.md §1.

Lossless canonical (cx fmt)

Lossless canonical (cx fmt) preserves every node: comments, anchors, aliases, merges, CX directives, BlockContent, RawText, processing instructions. It normalizes only presentation: whitespace, indent, quoting, number formatting.

  • Idempotent: fmt(fmt(x)) == fmt(x) for any valid x.
  • Use cases: opinionated source-file formatter; diff stability across edits that don't change data; code review where reviewers see only meaningful changes.

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.

Strict canonical (cx canonical / cx hash)

Strict canonical (cx canonical) reduces input to data-equivalence form. Strips comments and directives, expands anchors and aliases, resolves merges, normalizes datetime offsets to UTC, renames declared ids to a deterministic document-order scheme. Two CX documents have identical strict canonical bytes if and only if they encode the same data. Attribute order is data — it is preserved, never sorted.

  • Use cases: cx hash (content-addressable hashing — SHA-256 of strict canonical bytes); signed configuration (sign the strict canonical bytes; verify after canonicalizing); deduplication keyed on data, not file bytes.
  • Strict canonical is what the identity story rests on. Two documents that "encode the same data" but differ on comments, whitespace, quoting, or anchor names still hash to the same bytes.

prog.cx

          [$cx:canonical [$cx:parse "[user
  [; regrettably noisy comment ]
  email='ada@example.com'
  id=42
  active=true]"]]
        
          $ cx prog.cx
'[user email=ada@example.com id=42 active=true]\n'
        

Fixture cx-076-canonical-strips-comment-keeps-attr-order in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.

The strict-canonical bytes are what the SHA-256 hash is taken over (see hash) and what the round-trip guarantee is checked against (see roundtrip).

JSON projection

JSON is the most common interchange surface. CX → JSON mapping is straightforward; JSON → CX is well-defined for the JSON subset that maps cleanly to CX's data model. Per spec/03-approved/core/canonical.md §5 and spec/03-approved/core/conversions.md §2.2 / §4.

prog.cx

        [$cx:to-format [user id=42 email='ada@example.com' active=true [roles [role admin] [role editor]]] "json"]
      
        $ cx prog.cx
'{\n  "user": {\n    "id": 42,\n    "email": "ada@example.com",\n    "active": true,\n    "roles": {\n      "role": [\n        "admin",\n        "editor"\n      ]\n    }\n  }\n}'
      

Fixture cx-075-to-format-json-nested-record in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.

Basic mapping: Attributes become object keys; element bodies of pure-element content become nested objects keyed by child element name; repeated identically-named children become an array; scalars map directly.

Mixed content and the lossless $tag envelope

Mixed content — text interleaved with inline elements — has no native JSON form. The default projection is lossy: the text runs collect into an array under a reserved "_" key and the inline elements become sibling keys, so run order is not recoverable. Under --lossless, an element instead emits as the reserved $tag envelope (spec/03-approved/core/conversions.md §2.2.1): $children is an ordered array of runs, so mixed content survives exactly.

prog.cx

          [$cx:to-format [para 'The function ' [code 'parse'] ' returns a value.'] "json"]
        
          $ cx prog.cx
'{\n  "para": {\n    "_": [\n      "The function ",\n      " returns a value."\n    ],\n    "code": "parse"\n  }\n}'
        

Fixture cx-072-to-format-json-mixed-content-default in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.

JSON → CX reverses this unconditionally: a $tag envelope reconstructs the element with its body in order. The round-trip is byte-identical against cx canonical output (cx eq accepts the recovered document); the envelope also carries attributes ($attrs + $attr-types), element metadata ($anchor / $merge / $id / $type), and [table] payloads ($cols / $rows).

Namespace handling

CX namespace declarations (xmlns:dc=URI) project as JSON object keys with the xmlns: prefix preserved. Qualified names like dc:title project as JSON keys with the colon preserved (JSON keys are arbitrary strings, so this is legal). Round-trip is lossless when consumers preserve the xmlns: attributes.

Number precision and BigInt

JSON numbers are IEEE-754 doubles per RFC 8259, with ambiguous integer support beyond 2^53-1. CX exports int64 integers as JSON numbers; bigint scalars export as JSON strings, with the type recovered in lossless mode via the cx:type sidecar (map positions) or the per-item {"cx:bigint": "…"} carrier (array positions) — see spec/03-approved/core/conversions.md §0.2. Sized variants (::i32, ::u64) keep their native number image; the width name rides the envelope's $type / $attr-types in lossless mode.

Floats with nan / inf / -inf are not reachable from pure CX arithmetic (CX floats are finite-only); at the FFI boundary the JSON module's nan-handling option controls null / string / error behavior.

null vs missing

JSON has no way to distinguish "key absent" from "key present with value null." CX preserves the distinction where JSON can express it: [user email=null] projects to {"user": {"email": null}}, while an empty element [user] projects to {"user": null} (an element with no attributes and no body has nothing to become an object of). JSON → CX reads absent keys as absent attributes (NOT as null).

YAML projection

YAML follows the JSON projection but uses YAML's mapping and sequence syntax — attributes and nested elements are mappings, repeated children a sequence:

prog.cx

        [$cx:to-format [service host='localhost' port=8001 tls=true [routes [route path='/health'] [route path='/metrics']]] "yaml"]
      
        $ cx prog.cx
'service:\n  host: localhost\n  port: 8001\n  tls: true\n  routes:\n    route:\n      - path: /health\n      - path: /metrics'
      

Fixture cx-069-to-format-yaml-nested-record in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.

The lossless $tag envelope is the same encoding in both projections (spec/03-approved/core/conversions.md §2.3.1): under --lossless, CX anchors and merges ride the envelope's $anchor / $merge keys.

YAML's loose schema (untyped scalars, multiple ways to spell the same value) is normalized on input: yes/true/True all map to canonical true, numeric strings stay strings, etc. The conversion is documented in spec/03-approved/core/conversions.md §5.

YAML tags (!!binary and the !!cx: family)

Two tag families are honored on YAML → CX parse: !!binary (base64 payload → CX :bytes — a: !!binary aGVsbG8= parses to [a 0x68656c6c6f]) and the CX-specific !!cx:T type carriers. Any other tag — including the standard !!str / !!int — is not interpreted: the whole scalar text, tag included, is kept as one string (!!custom foo parses to the string '!!custom foo'). On emit, ambiguity is prevented by quoting, not tagging: a numeric-looking string emits as '42'; bytes emit with !!binary.

Multiline scalars (| and >)

On input, YAML's literal (|) and folded (>) block styles parse per YAML 1.2 — newlines preserved or folded respectively. On emit there is no block-scalar form: the canonical YAML emitter is block-style for structure but writes multi-line string values as ordinary quoted scalars with escaped newlines (body: "line one\\nline two"), which round-trip exactly.

Anchors and aliases

YAML-native anchors (&name) and aliases (*name) are resolved (expanded) on YAML → CX parse — the data content is preserved, the anchor/alias structure is not (spec/03-approved/core/conversions.md §5.1). In the other direction the default YAML emit is symmetric: CX anchors and merges are resolved — aliases expand to their referent, merge hosts inherit — so the YAML carries the semantic document (the Resolved AST, ast.md). The lossless envelope instead carries the authored structure via $anchor / $merge keys, which the importer reconstructs — so a --lossless round-trip preserves them.

TOML projection

CX → TOML projects the element tree onto TOML tables: each nested element becomes a section whose header is the quoted dotted path from the root, and repeated identically-named children become an array of tables. Per spec/03-approved/core/conversions.md §2.4 / §6.

prog.cx

        [$cx:to-format [config [server host='localhost' tls=true] [users [user id=1 email='ada@example.com'] [user id=2 email='grace@example.com']]] "toml"]
      
        $ cx prog.cx
'[config]\n["config.server"]\nhost = "localhost"\ntls = true\n\n\n["config.users"]\n[["config.users.user"]]\nid = 1\nemail = "ada@example.com"\n\n\n[["config.users.user"]]\nid = 2\nemail = "grace@example.com"\n\n\n\n'
      

Fixture cx-068-to-format-toml-sections in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.

Section headers, not inline tables

TOML allows three spellings for nested scalars: inline tables (server = { host = "x" }), dotted keys (server.host = "x"), and section headers. All three parse; the emitter's choice is structural, not size-based — there is no entry-count threshold. Every nested element emits as a section with a quoted dotted-path header (["config.server"]), scalars as key = value lines beneath it.

Array of tables

[[users]] repeated on the TOML side produces an array of maps on import (TOML → CX yields map / array values, not elements). CX → TOML emits [["path.to.name"]] — the quoted-dotted-path array-of-tables header — for any sibling group of identically-named child elements, preserving order; a one-of-a-kind child emits a plain quoted section header, never a dotted key.

XML projection

XML is the closest projection — both CX and XML are element-with-attributes-and-body languages. The mapping is essentially one-to-one for element / attribute / text content. Per spec/03-approved/core/canonical.md §8 and spec/03-approved/core/conversions.md §2.1 / §3.

prog.cx

        [$cx:to-format [user id=42 email='ada@example.com' [profile [bio 'Computing pioneer.']]] "xml"]
      
        $ cx prog.cx
'<user id="42" email="ada@example.com">\n  <profile>\n    <bio>Computing pioneer.</bio>\n  </profile>\n</user>'
      

Fixture cx-077-to-format-xml-nested-record in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.

Differences from XML: CX has no DTD validation layer (schemas are first-class CX, see schema); namespace declarations are CX attributes with the xmlns: prefix preserved; CDATA sections project to CX raw-text nodes ([#…#]), whose contents are opaque bytes.

Processing instructions and comments

XML processing instructions (<?xml-stylesheet …?>) project to CX directive elements ([?xml-stylesheet …]). XML comments (<!-- … -->) project to CX comments ([; … ]). Both round-trip losslessly in the lossless canonical form; the strict canonicalizer strips comments per canonical-strict.

xml:space round-trip

xml:space=preserve survives XML round-trip identically — as an inert pass-through attribute. CX gives it no engine semantics: quoted body text is verbatim anyway (see xml-space), so there is nothing for the attribute to switch. It rides along for XML consumers that honor it.

DOCTYPE and DTD

CX does not have a DTD layer. XML input with a DOCTYPE declaration is accepted; the declaration is captured as a DoctypeDecl prolog node on the document (<!DOCTYPE html> imports as [!DOCTYPE html] ahead of the root element). DTD internal-subset content (<!ELEMENT>, <!ATTLIST>, <!ENTITY>) is carried verbatim inside that node and is not used for validation — schemas are first-class CX (.cxs files, see schema).

Markdown projection

Markdown is a typed-lossy codec, not one of the lossless data projections (spec/03-approved/core/conversions.md §7). MD → CX yields a [doc]-rooted prose tree: headings become [h1] [h2] …; paragraphs become [p]; lists become [ul] / [ol] / [li]; inline code becomes [code]. The MD → CX → MD direction is byte-stable for the supported subset, but CX ↔ MD in general is lossy — element names, attributes, and types outside the prose vocabulary do not survive.

prog.cx

        [$cx:from-format "# Hello

A paragraph with *emphasis* and `code`.

- item 1
- item 2" "md"]
      
        $ cx prog.cx
[doc [h1 Hello] [p 'A paragraph with ' [em emphasis] ' and ' [code code] '.'] [ul [li item 1] [li item 2]]]
      

Fixture cx-074-from-format-md-prose-tree in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.

GFM tables

A GFM pipe table in Markdown input is not decomposed into [tr] / [th] / [td] structure — the table's source lines are carried as raw text inside a [table] element, and re-emit reproduces them byte-for-byte.

In the other direction, a CX [table[…]] block (see tables) emits as a real GFM pipe table — header row from the declared column names, then one row per data row — per spec/03-approved/core/conversions.md §7:

prog.cx

          [$cx:to-format [$cx:parse "[doc [p 'Two people:'] [table [table[name age::int]]
  Ada 35
  Grace 36
]]"] "md"]
        
          $ cx prog.cx
'Two people:\n\n| name | age |\n| --- | --- |\n| Ada | 35 |\n| Grace | 36 |'
        

Fixture cx-079-to-format-md-table-block-is-gfm in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.

Fenced code with language

Fenced code blocks ( lang ... ) project to a [code] element with a bare lang attribute and the code carried verbatim as raw text — [code lang=v [#println(1)#]]. There is no [pre] wrapper. The language identifier round-trips.

Frontmatter

YAML frontmatter (between --- markers at top of file) projects to attributes on the [doc] root element. Values stay strings — a frontmatter date: is not auto-typed into a CX date.

prog.cx

          [$cx:from-format "---\ntitle: My Post\ndate: 2026-05-22\n---\n\nHello, world." "md"]
        
          $ cx prog.cx
[doc title='My Post' date='2026-05-22' [p 'Hello, world.']]
        

Fixture cx-071-from-format-md-frontmatter in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.

Footnotes

GFM footnote syntax (text[^1] … [^1]: ref) is not implemented — there are no [fn-ref] / [fn-def] elements. Footnote markers are treated as ordinary inline text by the prose parser and do not round-trip as footnotes. Keep footnotes out of Markdown that must survive a CX round-trip.

Math blocks

Math syntax ($inline$, $$display$$) is not implemented — there is no [math] element. Dollar-sign math is treated as ordinary inline text and is not protected from the prose parser's emphasis and pairing rules. Keep LaTeX math out of Markdown that must survive a CX round-trip.

Delimited projection (CSV / TSV / PSV)

CSV (comma-separated), TSV (tab-separated), and PSV (pipe-separated) all project via the same delimited-data pipeline. Per spec/03-approved/core/canonical.md §9 and spec/03-approved/core/conversions.md §8. Delimited round-trip is well-defined but lossy (column types are not preserved on emit, only on the in-memory CX side).

prog.cx

        [$cx:from-format "id,name,email,active
1,Ada,ada@example.com,true
2,Grace,grace@example.com,false" "csv"]
      
        $ cx prog.cx
[table [table[id::int name email active::bool]]
  1 Ada ada@example.com true
  2 Grace grace@example.com false
]
      

Fixture cx-070-from-format-csv-typed-table in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.

Header row

The first row is always the header — its cells become the column names of the imported [table[…]] block. There is no header-detection heuristic and no flag to declare the header absent; headerless data needs a header row added before import.

Type inference

Per-column type inference scans the whole column: all values match int → ::int; all match int or float → ::float; all bool → ::bool; all date or datetime → ::date / ::datetime; otherwise the column stays string (the untyped default). One odd value anywhere in the column is enough to keep it string — inference never truncates its scan.

Quoting and escaping

RFC 4180 rules: fields containing the delimiter, double quote, or newline are wrapped in double quotes; internal double quotes are escaped as "". Output canonicalizes to RFC 4180 regardless of input quoting style. Lone CR / LF outside quotes are treated as row terminators; embedded CRLF inside a quoted field is preserved verbatim.

CSV → CX → CSV is byte-identical for RFC-4180-conformant input (quoted fields and escaped quotes included). The emit direction is defined only for table-shaped documents: a CX document that is not a single flattenable table refuses with a named emit_delimited error rather than guessing a tabular shape.

prog.cx

          [$cx:to-format [$cx:parse "[table [table[id::int name email active::bool]]
  1 Ada ada@example.com true
  2 Grace grace@example.com false
]"] "csv"]
        
          $ cx prog.cx
'id,name,email,active\r\n1,Ada,ada@example.com,true\r\n2,Grace,grace@example.com,false\r\n'
        

Fixture cx-073-to-format-csv-from-table in deps/cx-core-code/conformance/stdlib/cx.cxd — replayed against this cx when the page was built.

Bytes scalar across surfaces

The :bytes scalar kind (see bytes) has a different wire form per surface — no surface has a single native binary form that all the others share. The CX↔surface bridge applies a deterministic encoding per surface:

surface wire form notes
CX (canonical) 0x<hex> hex digits, even count; the only literal form
data-bin length-prefixed raw no encoding overhead
ast-bin length-prefixed raw same as data-bin
JSON string (base64) lossless adds the cx:type sidecar; JSON has no native bytes
YAML string with !!binary tag YAML 1.2 binary tag
XML hex text + cx:type="bytes" the sidecar attribute is emitted unconditionally, not only under --lossless
TOML string (base64) TOML has no binary type
Markdown HTML-comment fallback a bytes-bearing element has no prose image; it emits as an HTML comment
CSV / TSV 0x<hex> text in the cell deserializer needs schema to know it is bytes
CXCol / Parquet binary column type native; zero-copy through the Arrow bridge

The table states the shipped behavior per projection: JSON / TOML emit base64 strings, YAML emits !!binary, XML emits the hex text with the cx:type="bytes" sidecar, and the lossless JSON projection recovers the bytes type via the cx:type sidecar / {"cx:bytes": …} carrier.

Round-trip guarantees and lossiness matrix

The round-trip contract is the single most important property of CX's surface design. Stated formally:

For any CX document D in canonical form, and any lossless projection format F (XML, or JSON / YAML under --lossless), the following identity holds: to_cx(to_F(D)) == D (as canonical bytes) For the lossy projections the loss is documented and bounded — see the matrix below and spec/03-approved/core/conversions.md.

The conformance suite includes round-trip fixtures for every supported projection; the runner shells out to cx --from=… --to=… in both directions and asserts byte equality (or documented-divergence) per format.

Lossiness matrix (reproduced from spec/03-approved/core/conversions.md §9):

from \ to CX XML JSON YAML TOML MD CSV
CX lossless lossless‡ lossy◊ / lossless▣ lossy◊ / lossless▣ lossy◊ lossy◊ lossy§
XML lossless lossless lossy◊ lossy◊ lossy◊ lossy lossy§
JSON adds struct adds struct lossless¶ lossless¶ lossless¶ lossy lossy§
YAML lossy\* lossy\* lossless¶ lossless¶ lossless¶ lossy lossy§
TOML lossless\*\* lossless\*\* lossless¶ lossless¶ lossless¶ lossy lossy§
MD lossy lossy lossy lossy lossy lossless lossy§
CSV lossy§ lossy§ lossy§ lossy§ lossy§ lossy§ lossless

Legend:

  • lossless — byte-identical round-trip within the format's expressive range.
  • ‡ — CX → XML is lossless when consumers preserve the cx: namespace attributes.
  • — lossless within the expressive range of that format.
  • ▣ — lossless under --lossless: the $tag structure envelope + value carriers (spec/03-approved/core/conversions.md §2.2.1 / §2.3.1) recover element documents byte-identically (strict-canonical eq). The default (idiomatic) projection keeps the ◊ losses. Program directives are outside every projection's lossless domain.
  • ◊ — CX → JSON/YAML/TOML/MD default projection with CXDM container Items (Array, Map, Sequence-as-Item): non-string Map keys are coerced to strings on emit (lossy); the Sequence-vs-Array distinction is lost (in lossless mode both recover, via the cx:key-type sidecar and the cx:seq carrier).
  • § — Delimited (CSV/TSV/PSV) is well-defined but lossy: type metadata is lost on emit; anchors, aliases, and merges are resolved (an aliased row projects as a full row); comments, multi-document, mixed content, and PIs are stripped or error; element names are lost on parse (recovered via schema or auto-typing — see delim-types).
  • \\* — YAML anchors and aliases are resolved (expanded) on parse; not reconstructed in CX output. Data content is preserved.
  • \\*\\* — TOML has no mixed content or comments, so CX output contains only what TOML can express; no loss within TOML's range.

Practically: you can store data as JSON for the JS ecosystem, convert to CX for selection / transformation / hashing, then convert back to lossless JSON for delivery — no information loss, no re-formatting drift, no string-mangling in between. The CSV / TSV / Markdown projections are documented one-way exports for their respective ecosystems; round-trip back into CX recovers structure but not the lossy-on-emit metadata. The CXCol / Parquet bridges are lossless for table-shaped data and one-way for everything else.

The hash is taken over strict canonical bytes (see canonical-strict), not over the surface form. Two documents that round-trip identically through a lossless projection hash to the same SHA-256 — including across surfaces. This is the foundation of CX's content-addressing story (see hash).