Kinda is a Zig-first binding framework for Elixir.
It is designed for projects that need to bind a large C API to BEAM without hand-writing every NIF, resource wrapper, and dispatch stub.
The shortest useful description is:
kindatreats a wrapper header as the binding surface- extracts functions into a framework-owned manifest
- extracts named C records and field types into that same framework-owned manifest
- preserves Clang-extracted function docs when comments are available
- preserves raw C parameter/return type facts in that manifest
- generates generic Zig/Elixir wrapper outputs
- lets consumer policy project those raw C facts into public Elixir typespecs
- lets the same typed projection describe public record/field types in a machine-readable signature manifest
- lets the consumer keep product-specific policy outside the framework
This makes kinda a better fit for projects like beaver, where the problem
is not “write one ergonomic NIF”, but “keep a large drifting native API
coherent over time”.
If you want the shortest stable repo-local proof of the wrapper/reporting surface, run:
mix kinda.wrapper.exampleUseful modes:
mix kinda.wrapper.example --json
mix kinda.wrapper.example --report-onlyTo verify the whole repo from the root, including:
- root tests
- wrapper-reporting JSON smoke
- bundled
kinda_example
run:
mix kinda.verifyThis is the command CI should prefer for repo-level verification.
If you only want the bundled application example from the repo root, run:
mix kinda.example.verifyIf you want the same thing without compiling the project first, run:
elixir examples/wrapper_reporting.exsIt prints:
- the generated Elixir wrapper manifest
- the machine-readable typed signature manifest
- the human-readable callback-bridge backlog report
- the machine-readable callback-bridge manifest
- including dirty scheduler metadata when the example policy emits it
The formal plan for how:
examples/kinda_example/
fit together is:
Core runtime/building blocks:
Kinda.ResourceKindKinda.CodeGenKinda.PrecompilerKinda.CodecKinda.CallbackRuntimefor the common BEAM callback/reply boundarykinda.callback_runtimefor Zig-side native-thread callbacks into BEAM
Wrapper extraction/generation blocks:
Kinda.Wrapper.FunctionKinda.Wrapper.ManifestKinda.Wrapper.ExtractKinda.Wrapper.GenerateKinda.Wrapper.PolicyKinda.Wrapper.CallbackBridge
That means kinda now has an explicit split between:
- generic wrapper extraction/generation
- consumer-owned policy
- callback-bridge backlog and runtime substrate
Kinda is not primarily a “write NIFs directly in Zig” library.
It is a framework for binding a C library through three layers:
- wrapper surface
- a
.hfile says what native functions and types you want to expose
- a
- framework surface
kindaextracts that surface into a normalized manifestkindagenerates generic wrapper outputs from the manifest
- consumer surface
- your project decides naming, scheduler routing, diagnostics variants, unsupported APIs, and callback-heavy backlog
For a large binding, this is the important compression:
kindaowns the generic mechanics- your library owns the semantics
Define a boundary codec, generate the raw NIF surface, and bind a resource kind directly to it:
defmodule Foo.Native do
use Kinda.Codec
end
defmodule Foo.CAPI.Raw do
use Kinda.CodeGen,
with: Foo.Generated,
root: Foo.CAPI,
codec: Foo.Native,
surface: :raw
@on_load :load_nif
def load_nif do
:erlang.load_nif(~c"path/to/foo_nif", 0)
end
end
defmodule Foo.CAPI do
use Kinda.CodeGen,
with: Foo.Generated,
root: __MODULE__,
raw_module: __MODULE__.Raw,
codec: Foo.Native,
surface: :public
end
defmodule Foo.Handle do
use Kinda.ResourceKind,
raw_module: Foo.CAPI.Raw,
codec: Foo.Native
endGenerated public wrappers call concrete functions in Foo.CAPI.Raw, unwrap
resource arguments, and pass only the returned value through Foo.Native.
The codec never selects a native function. Keeping :public wrappers and
:raw NIF stubs in separate modules lets large bindings compile both surfaces
independently and avoids a generated proxy layer. The raw module owns NIF
loading, so the native entry module must be Elixir.Foo.CAPI.Raw.
Generated use Kinda.CodeGen modules now expose one formalized declaration
surface from the same typed source:
- the resolved declaration IR through
__kinda_declaration_surfaces__/0
When a generator module implements declaration_manifest/0, Kinda.CodeGen
can now source generated function/type declarations directly from that unified
manifest instead of reconstructing them from parallel callbacks. That callback
is now the canonical declaration interface in kinda: it may return a loaded
manifest value or a checked-in .ex / .json sidecar path. Kind-derived
helper NIFs still come from kinds()/0.
kinda now also exposes a top-level downstream declaration facade through
Kinda.Declaration. That facade sits over the same underlying
Kinda.CodeGen.DeclarationSurfaces IR and formalizes the distinction between
the declaration source and the generated module surface:
Kinda.Declaration.load_source/1reads the canonical source contract from a generator moduleKinda.Declaration.from_generator/2resolves that source contract into the final generated surfaces for a specific root module, including normalizednif_names, kind-derived helper entries, guaranteed/materialized generatedTypeDecls, and the derived signature view- that resolved surface lands as the formal framework-owned IR:
Kinda.CodeGen.DeclarationSurfaces
Downstreams should treat Kinda.Declaration.from_generator/2 as the formalized
public resolution interface. Generated modules expose that same resolved IR
directly through __kinda_declaration_surfaces__/0.
Inside that IR, the canonical resolved payload is the declaration manifest
itself; nif_decls, type_decls, and signature_manifest are no longer
stored a second time on the resolved surface. That lets downstream repos such
as Beaver unify their own public rewrite/pass DSL names without re-owning the
declaration contract.
When that manifest includes projected records, Kinda.CodeGen also emits
deterministic public type aliases such as foo_handle_record()/0 from the same
single source. These generated aliases use atom field keys derived from
extracted C field names, while the machine-readable manifest keeps the
original string names.
For larger generated bindings, the newer wrapper pipeline is usually more important.
The wrapper pipeline is the part of kinda that scales to large native APIs.
manifest = Kinda.Wrapper.Extract.from_clang_ast(ast_json)The result is a framework-owned shape:
%Kinda.Wrapper.Manifest{
records: [
%Kinda.Wrapper.CRecord{
name: "MlirContext",
kind: :struct,
fields: [
%Kinda.Wrapper.CField{
name: "ptr",
ctype: %Kinda.Wrapper.CType{spelling: "const void*", kind: :pointer}
}
]
}
],
functions: [
%Kinda.Wrapper.Function{
name: "mlirContextCreate",
params: [],
arity: 0,
doc: "Creates a new MLIR context.",
param_ctypes: [],
return_ctype: %Kinda.Wrapper.CType{
spelling: "MlirContext",
kind: :unknown
}
}
]
}defmodule MyLib.WrapperPolicy do
@behaviour Kinda.Wrapper.Policy
def generation_blocker_entries, do: %{}
def generation_blocked?(_name), do: false
def generation_blocker_reason(_name), do: nil
def callback_bridge_entries, do: %{}
def callback_bridge?(_name), do: false
def callback_bridge(_name), do: nil
def variants(name), do: [{:normal, name, name}]
def public_name({_kind, public, _base}), do: public
def elixir_params({_kind, _public, _base}, params), do: params
def dirty({_kind, _public, _base}), do: false
def typespec_field(_record, field), do: ...
def typespec_params(_variant, function), do: ...
def typespec_return(_variant, function), do: ...
def zig_entry({_kind, _public, base}), do: ~s{nif("#{base}"),}
endKinda.Wrapper.Generate.render_elixir_manifest(manifest, MyLib.WrapperPolicy)
Kinda.Wrapper.Generate.render_zig_nif_entries(manifest, MyLib.WrapperPolicy)
Kinda.Wrapper.Generate.declaration_surfaces_struct(manifest, MyLib.WrapperPolicy)
Kinda.Wrapper.Generate.declaration_manifest_struct(manifest, MyLib.WrapperPolicy)
Kinda.Wrapper.Generate.declaration_manifest(manifest, MyLib.WrapperPolicy)Not every native function should be emitted as a plain generated wrapper.
Some functions need:
- callbacks back into BEAM
- richer decoder logic
- lifetime review
- scheduler review
Kinda now represents that explicitly with Kinda.Wrapper.CallbackBridge.
Example:
Kinda.Wrapper.CallbackBridge.required(:some_function,
scheduler: :dirty_cpu,
facets: [:beam_callback, :scheduler_contract]
)This metadata does not invent a consumer ABI automatically. It is the framework-owned policy layer that lets a consumer say:
- this API is not “missing”
- this API belongs to the callback-bridge backlog, or is resolved by the dispatcher runtime
Pure kind-surface gaps belong to a different path. In a consumer such as
beaver, handle-like CAPIs are often unblocked by extending the Zig-side kind
surface and the matching Elixir KindDecl surface; callback-bridge metadata is
specifically for the remainder that kind-surface sync cannot solve.
Consumers can implement those callback bridges with the generic Zig runtime:
const kinda = @import("kinda");
const Dispatcher = kinda.callback_runtime.Dispatcher(.{ "run", "destruct" });
const dispatcher = try Dispatcher.initWithOptions(handler_pid, .{
.timeout_ms = 30_000,
});
dispatcher.setCallback("run", run_callback_term);
const response = try dispatcher.invoke("run", message_env, .{argument_term});On the BEAM side, the consumer supplies its own reply NIF while Kinda normalizes success, expected failure, and exceptions:
Kinda.CallbackRuntime.invoke(
reply_token,
fn -> {:ok, run_callback.()} end,
&MyNative.my_raw_callback_reply/2
)Callbacks with scalar, enum, or projected handle results use
invoke_reply/4. The consumer validates its own resource before completing
the shared token:
Kinda.CallbackRuntime.invoke_reply(reply_token, callback, fn token, outcome ->
MyNative.reply_projected_result(token, outcome)
end)The consuming NIF library exports and opens the shared reply resource:
const callback_nifs = .{
kinda.callback_runtime.ReplyToken.nif("my_raw_callback_reply"),
kinda.callback_runtime.ReplyToken.codeNif("my_raw_callback_reply_code"),
kinda.callback_runtime.ReplyToken.cancelNif("my_raw_callback_cancel"),
};
export fn nif_load(env: kinda.beam.env, _: [*c]?*anyopaque, _: kinda.beam.term) c_int {
kinda.callback_runtime.ReplyToken.open(env);
return 0;
}Dispatcher owns copied callback terms and its persistent environment. Each
invocation sends
{callback_name, reply_token, callback_fun, dispatcher_id, ...args}, then
waits on a non-scheduler native thread. The process replying through the
consumer-exported NIF becomes the next callback owner. Native callback
signatures, argument conversion, domain state, and diagnostics remain the
consumer's responsibility.
Waits are bounded to 30 seconds by default. A response reports whether it was
replied, canceled, dropped, or timed out; completion after any terminal state
returns stale. Dropping the reply resource or terminating its owner therefore
cannot leave a native worker waiting forever. ReplyToken and consumer-owned
registration resources use NIF resource-type takeover so live resources keep
the correct native library generation pinned across reload and unload.
kinda.callback_adapter provides the reusable projection shapes used by
consumer ABI trampolines: one handle, ranges of handles, scalar results, enum
results, and validated consumer projections. It never interprets an MLIR- or
library-specific handle.
Callback-bridge manifest version 2 records runtime_backed, runtime,
owner, destructor, lifetime, scheduler, and timeout_ms. Entries built
with Kinda.Wrapper.CallbackBridge.runtime_backed/2 no longer carry a
callback_bridge_required blocker and their consumer-provided declaration
variant remains in the normal resolved declaration surface.
Kinda now exposes one canonical declaration contract plus one derived signature view around that wrapper surface:
- resolved declaration surfaces
- unified declaration manifest
- callback-bridge manifest
Resolved declaration surfaces:
Kinda.Wrapper.Generate.declaration_surfaces_struct(manifest, policy)This is now the canonical wrapper-side in-memory declaration IR in kinda.
It carries:
- the source declaration-manifest slot, set to
nilfor wrapper-generated surfaces - the canonical resolved declaration manifest
Unified declaration manifest:
Kinda.Wrapper.Generate.declaration_manifest_struct(manifest, policy)
Kinda.Wrapper.Generate.declaration_manifest(manifest, policy)The declaration-manifest struct is now the canonical resolved payload stored
inside DeclarationSurfaces, and the map form is the JSON-friendly
serialization. It keeps:
- named C records and fields from extraction
- raw C param/return type facts from extraction
- consumer-projected public params and return typespecs
- consumer-projected public record and field typespecs
- generated
NIFDeclentries - generated
TypeDeclentries - dirty scheduler metadata on emitted variants
- generation-blocker reasons when the function is not emitted as a plain generated wrapper
Internally, Kinda.CodeGen.DeclarationManifest.build/2 is now the canonical
way to derive declaration metadata from typed wrapper facts, including the
generated TypeDecl layer. That keeps type declarations sourced from the same
declaration contract rather than from a parallel signature-only path.
When that declaration contract is consumed by use Kinda.CodeGen, the formal
resolution path now lives in Kinda.CodeGen.DeclarationSurfaces.from_generator/2,
so downstreams do not have to reimplement normalization or merge logic just to
observe the final generated declaration surface. That resolved surface is now
materialized as Kinda.CodeGen.DeclarationSurfaces, rather than an ad hoc map.
The typed signature manifest remains available as a derived compatibility view:
Kinda.Wrapper.Generate.signature_manifest(manifest, policy)The callback-bridge backlog remains a separate reporting mode:
- human-readable report
- machine-readable manifest
Human-readable:
Kinda.Wrapper.Generate.render_callback_bridge_report(manifest, policy)Machine-readable:
Kinda.Wrapper.Generate.callback_bridge_manifest(manifest, policy)The manifest is versioned and JSON-friendly:
{
"version": 1,
"entries": [
{
"function": {
"name": "mlirTypeConverterAddConversion",
"arity": 1,
"params": ["converter"]
},
"callback_bridge": {
"function": "mlirTypeConverterAddConversion",
"reason": "callback_bridge_required",
"unblock_path": "callback_bridge_runtime",
"scheduler": "unspecified",
"facets": ["beam_callback", "rich_input_decoder"]
}
}
]
}This is the main new “reporting surface” for consumers and CI.
The repo-local example above exercises this end to end.
Kinda ships Kinda.Precompiler, which consumers can use with elixir_make
precompiled builds.
Example:
def project do
[
make_precompiler: {:nif, Kinda.Precompiler}
]
endToday this is target-selection substrate, not a full RustlerPrecompiled-style product story.
- binding large C APIs with repetitive structure
- generating resource-centric Elixir/Zig surfaces
- separating framework mechanics from consumer policy
- keeping callback-heavy APIs visible as backlog instead of hiding them in a flat blacklist
- acting as a binding substrate for projects that drift with upstream native APIs
Kinda is not yet a Rustler-complete framework.
What is still missing:
- policy-driven generation from callback metadata to runtime adapters
- richer scheduler-aware NIF declaration surface
- a complete prebuilt/download/checksum story
- a more polished one-command reporting UX
- wrapper split plan:
- real consumer analysis from
beaver:
Kinda is already useful as a framework substrate.
It is now moving from:
- “resource kind + codegen helper”
toward:
- “wrapper-driven Zig/Elixir binding framework with explicit policy and backlog surfaces”
That is the right frame to evaluate future work.