Dextrin
Dextrin is an Elixir implementation of DXN (Data eXchange Notation): a
human-writable text format (.dxn), a compact binary format (.dxnb)
built on CBOR, and a schema format (.dxns) that's just more DXN data
— all three sharing one in-memory value representation and one
extension mechanism.
{:ok, value} = Dextrin.decode(~s(%{x: 1, y: 2}))
value
#=> %{x: 1, y: 2}
Dextrin.encode(value)
#=> {:ok, "%{x:1,y:2}"}
{:ok, bytes} = Dextrin.encode_binary(value)
Dextrin.decode_binary(bytes)
#=> {:ok, %{x: 1, y: 2}}
A plain map's shorthand keys (x:) are themselves DXN keywords — %{x: 1}
and %{:x => 1} are the exact same value, and keyword's own Elixir type,
per DXN.md §1.3, is a real atom, which is what both decode/2 and
decode_binary/2 produce by default (trusted: true). Encoding accepts a
bare atom the same way, anywhere a keyword is expected — %{x: 1} or
Dextrin.encode(:ok) both work with no conversion needed first.
That default assumes a source you control — your own config, your own
application's data — not arbitrary untrusted/network input, where an
unbounded String.to_atom/1 could exhaust the atom table. Pass
trusted: false for that case; keyword then decodes as Dextrin.Keyword.t()
instead:
Dextrin.decode(~s(%{x: 1, y: 2}), trusted: false)
#=> {:ok, %{%Dextrin.Keyword{name: "x"} => 1, %Dextrin.Keyword{name: "y"} => 2}}
A schema-backed struct's fields are the one place names do come back
as plain strings regardless of trusted: — see the
tutorial — since a schema always knows its field
names up front.
.dxn's text grammar is compiled by Ichor
— write the grammar once (priv/grammar/dxn.aether), get a lexer,
parser, and (via Dextrin.Text.Actions) an evaluator with no
hand-written parsing code. That compilation happens ahead of time
(mix ichor.gen, checked in as generated source), not at
dextrin's own build time, so only the small ichor_runtime support
library the generated code actually calls ships as a real dependency —
ichor proper (the Aether front-end, analysis, codegen) is dev-tooling
only. .dxnb has no grammar to speak of — it's a direct, hand-rolled
CBOR codec — so it's plain recursive Elixir working over the same
shared value type.
Why
Most serialization formats pick one point on a spectrum: JSON is
human-writable but loses precision (no distinct int/float boundary,
no dates, no bytes) and has no extension story; Protobuf/Avro are
compact and typed but need a separate schema-compiler step and aren't
meant for a human to read or hand-edit; EDN is expressive and
Elixir-friendly in spirit but has no first-party Elixir implementation
and no binary counterpart. DXN's premise is that a .dxn text
document and a .dxnb binary document should be the same value
space — 30 scalar/collection/temporal/extended types
(reference §1.3), precise enough
for money (Decimal), exact ratios (Rational), and arbitrary
precision integers, with struct and custom-tag as first-class,
schema-describable extension points — encoded however density or
readability happens to matter for a given use.
Components
Dextrin— the four-function public API:decode/2,encode/2(.dxntext) anddecode_binary/2,encode_binary/2(.dxnbbinary). One error type,Dextrin.Error, for both.- Value types (
Dextrin.Symbol,Dextrin.Tuple,Dextrin.OrderedMap,Dextrin.SortedSet,Dextrin.Struct,Dextrin.Array,Dextrin.Duration,Dextrin.Rational,Dextrin.Uuid,Dextrin.Uri,Dextrin.Bytes,Dextrin.Char,Dextrin.CustomTag) — small wrapper structs for the DXN types Elixir has nothing native for without losing information.keywordis the one exception with two faces: a real atom by default (trusted: true),Dextrin.Keywordwhen decoded untrusted. Every other scalar/collection (integers, floats, strings, lists, plain maps, sets, dates, regexes, ...) decodes to the obvious native Elixir value. Dextrin.Text.Grammar/Actions/Printer/Formatter— the.dxnpipeline: an Ichor-compiled grammar (Grammaris a thin wrapper around the pregeneratedGrammar.Native), anIchor.Actionsimplementation that turns a parse into real values, a single-line printer (the reverse direction), and a multi-line pretty-formatter on top of it.Dextrin.Binary.Encoder/Decoder/Tags— the.dxnbpipeline: a direct recursive CBOR codec (not built on a generic CBOR library — seeDextrin.Binary.Encoder's own moduledoc for why) plus the private tag block and bit-layout constants it needs.Dextrin.SchemaandDextrin.Schema.*— compiles a.dxnsdocument (itself just DXN data — no new grammar) into aDextrin.Registry, enforced automatically, decode- and encode-side, wherever a registered struct name appears.Dextrin.Schema.Stdships a small standard library of common named types (PositiveInteger,NonEmptyString, ...);Dextrin.Schema.FileResolverresolvesNamespace/Namereferences across separate.dxnsfiles;Dextrin.Schema.Providerlets a struct's own library ship its DXN schema without that library ever depending ondextrinitself.Dextrin.Registry— the one extension point bothstructandcustom-tagshare: register a tag decoder/encoder, a struct materializer, or a lazy schema resolver; also carries thetrusted:/put_trusted/2flag that decides howkeyworddecodes. Plain immutable data, threaded explicitly — never a process or ETS table.mix dextrin.*—validate,encode,decode,format,gen.schema(scaffold a.dxnsfile from an existing Elixir struct), andgen.unicode(regenerate the grammar's Unicode identifier ranges from the latest UCD data).
Installation
Add dextrin to your list of dependencies in mix.exs:
def deps do
[
{:dextrin, "~> 0.1.0"}
]
end
Where to go next
- Tutorial — a step-by-step walkthrough of this library's features, building up to a small, working example that decodes, validates against a schema, and re-encodes real data.
- Examples — worked examples: config files, API payloads, event logs, and schema-validated records.
- Cheatsheet — quick reference for common
Dextrintasks. - DXN tutorial and DXN reference — everything about the DXN format itself, independent of this Elixir implementation, plus DXN examples and a DXN cheatsheet.
Development
mix deps.get
mix precommit
mix precommit runs the full verification pass this project expects
before a commit: mix format, mix compile --warnings-as-errors,
mix credo --strict, mix sobelow, mix test, and mix dialyzer, in
that order (fast/cheap checks first, dialyzer — the slowest, especially
its first PLT build — last).
priv/grammar/dxn.aether's generated Unicode identifier ranges are
regenerated with mix dextrin.gen.unicode — a deliberate, reviewed
action on a Unicode version bump, never run automatically at build
time (see that task's own docs). Either way, changing the grammar
itself requires a mix ichor.gen step afterward, since
lib/dextrin/text/grammar/native.ex is generated ahead of time, not
produced at dextrin's own compile time (that's also what keeps
ichor itself, and everything it depends on for parsing/codegen,
only: :dev, runtime: false — only the small ichor_runtime package
ships in a release) — see CONTRIBUTION.md.
See CONTRIBUTION.md for how to propose changes, and CHANGELOG.md for release history.
License
MIT — see LICENSE.