How KiwiCodec is put together, for people changing it. Using the Rustler generator is covered in Native decoders.
Layout
lib/is the codec: the schema parser and binary schema format (KiwiCodec.Schema.*), wire primitives (KiwiCodec.Wire.*), the DSL and the modules it generates (KiwiCodec.DSL,KiwiCodec.GeneratedModule.*), encoding and decoding of generated structs (KiwiCodec.Encoder,KiwiCodec.Decoder), and the schema interpreter. It has no dependency beyondvarint.codegen/is the Rustler generator. Every module there is wrapped inif Code.ensure_loaded?(RustQ), because RustQ is optional: projects that depend on KiwiCodec get the generator only where they also depend on RustQ.- Tests mirror the source tree.
test/package/builds the package and compiles it in two consumer projects, one without RustQ and one generating Rust.
Rustler generator
The generator serves two goals at once:
- Keep generator code semantic and maintainable in Elixir.
- Keep generated Rust compact enough for large schemas, such as Figma's.
For repetitive schema decoders, those goals are served by a compact macro boundary rather than by fully expanding every decoder body.
Layers
The generator is organized as a small pipeline:
- Schema definitions are parsed and selected by
KiwiCodec.RustlerGenerator. - One generator per feature,
Full,Sparse,Skip, andRecords, turns each definition into a call to a compact Rust macro, such askiwi_message_decoder!,kiwi_sparse_struct_decoder!, orkiwi_skip_message_decoder!, with a row per field.Fieldsays how a field is read or skipped, andNamenames every generated function. - Helper modules define those macros and the functions they call, with RustQ
defrustmacroanddefrusttyped against the referencedecoder.rsbesideKiwiCodec.RustlerGenerator.Decoder:Valuesfor what full and sparse decoders share,Full.Helpers,Sparse.Helpers, andSkip.Helpersfor each feature, andSparse.Messages,Skip.Messages, orSparse.SkipMessagesfor messages, depending on which features are generated together.Primitivedefines the readers and skippers of each primitive type from one table ofDecodermethods. KiwiCodec.RustlerGenerator.Spliceincludes each needed helper module whole, through its__rustq_items__/0.KiwiCodec.RustlerGenerator.Entrypointemits a NIF per entrypoint callingkiwi_decode_binarywith the definition's decoder.
This split keeps schema logic in Elixir while avoiding thousands of repeated expanded Rust function bodies.
Three pieces are shared by every schema and written as Rust under templates/:
the record runtime, the runtime schema that skips unknown fields, and the atom
cache. They are ordinary Rust APIs, documented for the Rust code that uses
them, rather than repetitive decoder shapes. The record runtime and atom cache
are named after the modules that provide them, templates/records.rs for
records.ex, so RustQ knows their signatures and RustQ code elsewhere names the
module rather than the file, and so is the runtime schema,
templates/unknown_field_runtime_schema.rs for
unknown_field_runtime_schema.ex, which UnknownFields includes in one mode.
Compact RustQ macro boundary
The macros selected by Splice are a compact RustQ-authored boundary. They are
shared implementations for highly repetitive decoder shapes, but they should be
authored through defrustmacro, defrust, type metadata, and RustQ AST helpers
rather than through raw Rust heredocs or ad hoc token strings.
When changing full, sparse, or skip decoder generation:
- Prefer semantic Elixir metadata and helpers first.
- Keep compact schema-specific output as macro invocations when the expanded body would be repetitive.
- Use RustQ AST or Rusty-Elixir helpers for local expression/arm/function generation where it remains compact, such as skip field arms used by downstream custom templates.
- Declare a
Decodermethod in the referencedecoder.rsbefore addingunwrap!, verbose propagationcaseexpressions, or duplicate method metadata to a helper. - Do not replace compact decoder macro invocations with fully expanded Rust just to say the generator is "AST-backed" or "defrust-backed".
A good change should make the Elixir generator clearer without increasing large schema output size.
Tests
KiwiCodec.RustlerGenerator.NativeTest builds the generated decoders for every
feature with the reference Decoder, loads them as NIFs, and checks what they
return against KiwiCodec.decode/2 and KiwiCodec.SchemaInterpreter. A
generator change that produces Rust that does not compile, or decodes
differently, fails there; tests of the generated text are not needed for it.
Checklist
Before committing generator changes:
- Generate Rust for a schema that exercises full, sparse, and skip decoders, before and after the change, and compare the size.
- Run
mix ci, which builds and runs the generated Rust. - Generate Figler's decoders with this checkout, as Figler's schema is the
largest consumer: set
KIWI_CODEC_PATHin Figler, runmix rustq.gen, and check that its crate compiles. Restore Figler's generated files afterwards.