Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

cheadergen generates accurate C headers for Rust libraries that expose a C-compatible API.

cheadergen provides:

  • Multi-crate support. One C header per crate, with cross-crate #includes wired up automatically.
  • Compiler-accurate type analysis. Type information comes from rustdoc-json, so the generated output mirrors what the Rust compiler actually sees.
  • Macro-aware. Items defined by declarative or procedural macros are picked up automatically.

cheadergen is an alternative to cbindgen. Check out our comparison page for more details.

What it does

You write Rust:

#![allow(unused)]
fn main() {
#[repr(C)]
pub struct Point {
    pub x: f64,
    pub y: f64,
}

#[unsafe(no_mangle)]
pub extern "C" fn distance(a: Point, b: Point) -> f64 {
    ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}
}

cheadergen produces a header your C code can consume:

typedef struct {
    double x;
    double y;
} Point;

double distance(Point a, Point b);

New to C/Rust interoperability?

If you’ve never written extern "C" in Rust before, the FFI chapter of the Rust Nomicon is a good primer on the building blocks cheadergen relies on: the C calling convention, #[no_mangle] for stable symbol names, #[repr(C)] for predictable struct layout, and the rules around passing data across the boundary.

Read through it first if any of those terms feel unfamiliar: the rest of this guide assumes you know what they do, and focuses on how cheadergen can support your mixed C/Rust projects.

Author

cheadergen (C Header Generator) is built and maintained by Luca Palmieri.

User guide structure

This guide is structured as follows:

Install cheadergen

cheadergen ships as a single binary called cheadergen. Since cheadergen relies on rustdoc-json for header generation, it requires a specific nightly toolchain to be installed via rustup.

Pick whichever installation method fits your environment best.

Prebuilt binaries

On macOS or Linux:

curl --proto '=https' --tlsv1.2 -LsSf \
  https://github.com/LukeMathWalker/cheadergen/releases/download/0.2.4/cheadergen_cli-installer.sh \
  | sh

The installer downloads the prebuilt binary for your platform and drops it into ~/.cargo/bin (or, if Cargo isn’t installed, ~/.local/bin).

On Windows, via Powershell:

powershell -ExecutionPolicy Bypass -c "irm https://github.com/LukeMathWalker/cheadergen/releases/download/0.2.4/cheadergen_cli-installer.ps1 | iex"

Installers download prebuilt binaries from our GitHub releases. Each archive ships with a sibling .sha256 file you can use to verify the download.

From crates.io

cargo install --locked cheadergen_cli

cheadergen_cli is the binary crate; the installed executable is named cheadergen. If you have cargo-binstall it will fetch the prebuilt artifacts:

cargo binstall cheadergen_cli

Nightly toolchain

cheadergen invokes cargo doc --output-format=json, which is only available on nightly. You don’t need to make nightly your default toolchain: cheadergen calls a pinned nightly via cargo +<toolchain>.

If the pinned nightly isn’t installed when cheadergen runs, header generation will fail with an error that explains how to install the missing toolchain.

If you want to install it eagerly, run:

rustup install nightly-2026-06-04 -c rust-docs-json

Verify the install

cheadergen --help
cheadergen generate --help

Both commands should print usage information without errors. You’re ready for the Quickstart.

Quickstart

This walkthrough takes you from an empty directory to a generated C header in about five minutes. It assumes you have already installed cheadergen.

1. Create a tiny Rust library

cargo new --lib distance
cd distance

Edit Cargo.toml so the crate compiles as a C-compatible static library:

[package]
name = "distance"
version = "0.1.0"
edition = "2024"

[lib]
# 👇 Change the crate type from the default `rlib` to `staticlib`
crate-type = ["staticlib"]

Replace src/lib.rs:

#![allow(unused)]
fn main() {
#[repr(C)]
pub struct Point {
    pub x: f64,
    pub y: f64,
}

#[unsafe(no_mangle)]
pub extern "C" fn distance(a: Point, b: Point) -> f64 {
    ((a.x - b.x).powi(2) + (a.y - b.y).powi(2)).sqrt()
}
}

Why these annotations? #[unsafe(no_mangle)] keeps the symbol name intact so a C linker can resolve it. extern "C" makes the function use the C calling convention so a C caller passes arguments and reads the return value the way Rust expects. Together they make distance callable from C. Without them, cheadergen has no signal that you intend the function to be reachable from C and skips it.

Same reasoning applies to Point: #[repr(C)] guarantees that the struct’s layout matches a C struct. Without it, cheadergen would refuse to emit a C definition for it.

2. Generate the header

cheadergen generate --output-dir include

You should see something like:

Generating headers for 1 crate(s) using toolchain `nightly-...`
Successfully loaded rustdoc JSON for `distance`: root module `distance`
`distance`: 1 function(s), 0 static(s), 0 constant(s)
Wrote target header: include/distance.h

3. Read the output

In include/distance.h you should see something along the lines of:

/* WARNING: this file was auto-generated by 
 * cheadergen from `distance`. 
 * Do not edit it manually. */

#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>

typedef struct {
    double x;
    double y;
} Point;

double distance(Point a, Point b);

That’s the whole loop. To understand how the generated C header is structured (and how to customise it), read Anatomy of a generated header.

Where to go next

Target packages

When you run cheadergen generate, the first thing it has to figure out is which Rust packages to generate headers for. That set is called the target packages.

This page explains what a target package is, how the set is computed, and why it matters when you reach for #[cheadergen::config(export)].

Target packages vs dependency packages

A target package is a package that cheadergen treats as a header-emission target. Its extern "C" functions, #[no_mangle] statics, and items annotated with #[cheadergen::config(export)] all get pulled into the output.

A dependency package is anything else cheadergen reaches while following type references: workspace siblings of a target, third-party crates from crates.io, the standard library. Its types may still appear in the output (because a target’s signature uses them), but its extern "C" items and #[cheadergen::config(export)] annotations are ignored.

The distinction is local to a single cheadergen generate invocation. The same crate can be a target in one run and a dependency in another, depending on the flags you pass.

How the target set is computed

cheadergen generate accepts three kinds of selectors. They combine in the order below; the final set is what gets fed to the rest of the pipeline.

--input-dir (repeatable)

cheadergen generate --output-dir include --input-dir <PATH>

<PATH> must be a directory: every workspace member whose manifest lives under that directory is selected. Pointing at the workspace root selects every workspace member; pointing at a subdirectory scopes the selection to the crates inside it.

Pass --input-dir multiple times to combine several directories in a single run. Each path must live inside the workspace rooted at the current working directory; a path outside that workspace, or one that contains no workspace members, is an error.

--package / -p (repeatable)

cheadergen generate --output-dir include -p alpha -p beta

Adds workspace members by name on top of whatever --input-dir selected. Each name must match a workspace member; unknown names produce an error.

If you pass --package flags without any --input-dir, the directory-based step is skipped entirely; only the named packages end up in the target set.

--exclude (repeatable)

cheadergen generate --output-dir include --exclude internal-tools

Subtracts workspace members by name from the set produced by the steps above. Excluding a package that isn’t in the set is an error.

Library-only filter

After all three selectors run, cheadergen drops any package without a library build target: cheadergen can only generate headers for library crates. If you explicitly named a binary-only package via -p, that’s an error; if a binary-only package was picked up implicitly (via --input-dir or the default), it’s silently skipped with a warning.

Default

With no --input-dir, no --package, and no --exclude, cheadergen mirrors Cargo’s default:

  • If the current working directory sits inside a workspace member’s directory, only that member becomes a target. (When several candidates contain cwd — e.g. a hybrid workspace where the root is a package and has nested members — the closest one wins.)
  • Otherwise the workspace’s [workspace] default-members are used. For a virtual workspace that expands to every member; for a non-virtual workspace it’s the root package.

So cheadergen generate run from crates/alpha/ targets only alpha, while the same command run from the workspace root targets every member (or whichever default-members configures).

Why this matters for #[cheadergen::config(export)]

#[cheadergen::config(export)] only takes effect when the defining package is a target. Annotations on a non-target package are silently ignored.

In practice that means:

  • If a type defined in a third-party crate carries an export annotation upstream, cheadergen won’t pick it up; that crate isn’t in your target set.
  • If a type defined in a workspace sibling carries export, you need to make sure that sibling is in the target set (via --input-dir, an explicit -p, or a cwd that resolves to it).
  • If cheadergen “doesn’t see” an annotation you added, the most common cause is that the defining package isn’t a target in this invocation.

Items that don’t rely on #[cheadergen::config(export)] for inclusion (extern "C" functions, #[no_mangle] statics) follow the same rule: they only become entry points when they live in a target package.

A worked example

Consider this workspace:

my-workspace/
├── Cargo.toml          # [workspace] members = ["alpha", "beta", "core", "cli"]
├── alpha/              # library, has extern "C" functions
├── beta/               # library, has extern "C" functions
├── core/               # library, shared types only — no extern "C"
└── cli/                # binary crate

Different invocations produce different target sets:

InvocationTarget packagesNotes
cheadergen generate … (run from the workspace root)alpha, beta, corecli dropped (binary-only, warning).
cheadergen generate … (run from alpha/)alphaDefault. core’s types may still appear if alpha uses them.
cheadergen generate … --input-dir alphaalphaSame selection, explicit form; works from any cwd inside the workspace.
cheadergen generate … -p alpha -p betaalpha, betacore is not a target; any #[cheadergen::config(export)] in core is ignored.
cheadergen generate … --exclude cli (from the workspace root)alpha, beta, coreSame as the default; explicit --exclude avoids the binary warning.

See also

Item annotations

cheadergen uses a procedural macro to customize header generation on a per-item basis: #[cheadergen::config(...)].

#[cheadergen::config(...)] can be applied to structs, enums, unions, type aliases, functions, constants or statics. It’s an item-level attribute.

From time to time, you may need field-level configuration. That’s done via #[cheadergen(...)], the member-level attribute. It only takes effect when the parent item also carries #[cheadergen::config(...)]; without it, the proc macro never fires and the inner attribute is ignored.

Both attributes accept a comma-separated list of directives, just like serde or clap:

#![allow(unused)]
fn main() {
#[cheadergen::config(export, opaque, rename = "Handle")]
pub struct InternalHandle { /* ... */ }
}

This page sketches the categories of directive so you know what’s available and where to look. The attribute reference on docs.rs is the authoritative list, including the validation rules.

The five categories

1. Inclusion control

Decides whether an item shows up in the header at all.

DirectiveMeaning
exportForce inclusion of a type that isn’t reachable from any extern "C" item.
skipExclude an item even if FFI traversal would normally pull it in.

export is meaningful only on types. Functions and statics with extern "C" + #[no_mangle] are auto-included; applying export to them is a compile error.

export is idempotent: applying it to a type that is already reachable from FFI has no inclusion effect, but the other directives on the same attribute still apply.

2. Shape

Changes how a type or field is rendered in C.

DirectiveWhereMeaning
opaquetypeEmit as a forward declaration (typedef struct Foo Foo;) when included.
field_names(a, b, …)tuple structAssign C field names to positional fields.
bitfield = NfieldEmit as a C bitfield of width N.
const_ptrfieldQualify the resulting C pointer as const T *.

opaque on its own doesn’t force inclusion: it only changes the rendering if the type is included for some other reason. Combine it with export if you also want it to appear regardless of FFI reachability.

3. Naming

Renames identifiers as they cross the Rust/C boundary.

DirectiveWhereMeaning
rename = "CName"type, function, static, field, variantOverride the C name.
rename_all = "..."struct, enum, unionBulk-rename fields or variants using a casing rule.
rename_all_fields = "..."enum (with struct variants)Bulk-rename fields inside the enum’s struct variants.
prefix_with_nameenumPrefix variant names with the enum name (e.g. Status_Ok).

The casing rules accept the same string literals as serde: "camelCase", "PascalCase", "snake_case", "SCREAMING_SNAKE_CASE". A per-field/per-variant rename = "..." always beats the bulk rename_all/rename_all_fields rule.

4. Validation

The proc macro rejects nonsensical combinations at compile time. A few of the more useful checks:

  • export and skip on the same item → error.
  • export on a function or static → error.
  • opaque on a function, static, or constant → error.
  • prefix_with_name on a non-enum → error.
  • field_names on a non-tuple struct → error.
  • rename_all_fields on an enum without any struct variants → error.
  • Unknown casing strings, unknown directives → error.

Errors fire when you compile your Rust crate, not when you run cheadergen — so you find out as part of your normal cargo build loop.

5. Type-aware runtime checks

A few directives can’t be fully validated by the proc macro because they depend on cheadergen’s type lowering:

  • const_ptr requires that the field’s resolved C type is a pointer (e.g. *mut T, NonNull<T>). If lowering produces a non-pointer, cheadergen fails at generation time with a clear error.

How it works under the hood

When the proc macro fires, it rewrites each directive into a #[diagnostic::cheadergen::...] attribute that can be seen in the JSON output of rustdoc. cheadergen then picks up those attributes when indexing that crate.

You don’t need to know any of this to use the attributes, but it’s a cool trick that’s worth sharing!

See also

Global configuration

Beyond CLI flags, cheadergen reads an optional cheadergen.toml. It’s where you keep project-wide customisation: anything you’d rather not retype on every invocation, or anything that doesn’t have a CLI flag at all.

This page covers how the file is loaded and what categories of options it holds. For the exhaustive option-by-option schema, see the canonical cheadergen.toml reference on docs.rs.

How it’s loaded

There is no auto-discovery. cheadergen only reads a config file when you point at one explicitly:

cheadergen generate --config cheadergen.toml --output-dir include

Without --config, cheadergen runs with the same defaults as an empty cheadergen.toml. Every option in the reference has a documented default.

The file’s location on disk doesn’t matter. A workspace can store its config anywhere convenient (cheadergen.toml at the workspace root, tools/cheadergen.toml, etc.) and pass the path through --config.

What it can configure

The config file is grouped by scope. At a glance:

  • Top-level keys are global emission defaults: preamble, after_includes, autogen_warning, pragma_once, no_includes, documentation, documentation_style, documentation_length, style, cpp_compat, usize_is_size_t, bundle, sort_by, …
  • [header.<name>] introduces overrides scoped to a single output header (e.g. [header.alpha] only affects alpha.h). Accepts most top-level keys plus include_guard.
  • [package.<name>] introduces overrides scoped to a single Rust package (e.g. [package."my-crate"]). Controls header_name, types = "opaque" | "skip", usize_is_size_t, and per-package emission tweaks.
  • [fn], [static], [constant] set the default sort order and emission rules for function declarations, static items, and constant macros.

Each of those subsections has its own page in the reference with the full list of keys and accepted values.

CLI vs config precedence

CLI flags trump the config file: --bundle, --cpp-compat, --style, and the rest override the corresponding TOML key when both are set. Per-header sections ([header.<name>]) in turn override the top-level keys. The full override priority is documented in the override priority section of the reference.

See also

The processing pipeline

When you run cheadergen generate, your crate flows through six stages before a single byte of C lands on disk. Understanding the order helps you reason about why a given item did (or did not!) end up in the generated C header(s).

1. Invoke rustdoc-json

cheadergen invokes cargo +<nightly> doc [...] --output-format=json for each target package. The Rust compiler does the heavy lifting: macro expansion, module resolution, type checking, name resolution.

2. Filter FFI items

cheadergen scans the JSON file emitted by rustdoc to determine which items are exposed to C:

  • Functions annotated with extern "C" and #[no_mangle].
  • Statics annotated with #[no_mangle].
  • Anything that explicitly opted into C visibility via #[cheadergen::config(export)].

Caution! cheadergen only picks up items annotated via #[cheadergen::config(export)] when the defining package is a target package. Annotations in non-target packages (workspace siblings you didn’t select, third-party dependencies, the standard library) are silently ignored.

3. Compute the crate closure

cheadergen then tries to determine the crate closure: the set of Rust types that are reachable from any of the items collected by the previous step (e.g. a struct used as input parameter in one of your extern "C" functions).

Keep in mind that a function signature in your crate can reference a type defined in a different crate (a workspace member, a dependency from crates.io, etc.). cheadergen walks each external reference and pulls in the defining crate’s rustdoc JSON on demand (recursively) until every reachable type has a definition.

This is also why dependency types can end up in your generated header at all, and why the partitioned output mode gives each defining crate its own header file.

The JSON files for third-party dependencies are cached on disk in cheadergen’s cache directory. Subsequent runs try to reuse them whenever possible.

Tip. You can pre-warm the cache for an entire workspace with cheadergen cache warm, inspect the cache directory with cheadergen cache show dir, and clear it with cheadergen cache clear. See Manage the rustdoc cache for the dedicated guide.

4. Build the IR

The raw rustdoc-types items are translated into cheadergen’s own intermediate representation (IR). The IR is closer to C than to Rust: pointers, primitives, tagged unions and bitfields are all explicit, and naming concerns (rename, prefix_with_name, casing rules) are resolved here.

5. Transform standard types

A handful of well-known Rust types are special-cased so the resulting C is idiomatic:

Rust inputC outputNotes
Box<T>T *
NonNull<T>T *Becomes const T * if the field is annotated with #[cheadergen(const_ptr)].
Option<&T> / Option<&mut T>const T * / T *
Option<Box<T>> / Option<NonNull<T>>T *Null-pointer optimization; same shape as the inner pointer.
Option<fn(...)>fn(...)Function pointers are inherently nullable.
Option<W> where W is #[repr(transparent)] wrapping an NPO typeWNull-pointer optimization is carried through transparent wrappers.
ManuallyDrop<T> / UnsafeCell<T> / MaybeUninit<T>TZero-cost wrappers are stripped before emission.
PhantomData<T> / PhantomPinned(field skipped)Zero-sized types aren’t emitted.
usize / isizeuintptr_t / intptr_tThey become size_t / ptrdiff_t if usize_is_size_t is set to true, either globally or per-package.
std::ffi::c_int, c_char, …C’s native int, char, …The full core::ffi / std::ffi C primitive aliases are recognised.

6. Emit

The final IR is rendered into one or more C headers. The exact shape (the order of sections, what gets #included, whether you get one file or multiple) is controlled by your cheadergen.toml and CLI flags. See Anatomy of a generated header and Bundled vs partitioned output for more details.

Generics and monomorphization

Rust generics don’t exist in C. Wrapper<i32> and Wrapper<f32> are treated as distinct C types: they have different field types, different sizes, different layouts. cheadergen resolves this by monomorphizing every concrete instantiation it sees into a separate, concretely-named C type.

This page explains the naming convention, the placement rule, and the duplicate-definition guards you’ll see in partitioned output.

Naming

A monomorphized instantiation gets a C name built from the generic’s name and the type parameters:

#![allow(unused)]
fn main() {
#[repr(C)]
pub struct Wrapper<T> {
    pub value: T,
}

#[unsafe(no_mangle)]
pub extern "C" fn unwrap_i32(w: Wrapper<i32>) -> i32 { w.value }

#[unsafe(no_mangle)]
pub extern "C" fn unwrap_f32(w: Wrapper<f32>) -> f32 { w.value }
}
typedef struct {
    int32_t value;
} Wrapper_i32;

typedef struct {
    float value;
} Wrapper_f32;

int32_t unwrap_i32(Wrapper_i32 w);
float unwrap_f32(Wrapper_f32 w);

For enums, each instantiation produces a small family of auxiliary types. For example,

#![allow(unused)]
fn main() {
#[repr(C, u8)]
pub enum Either<T> {
    Left(T),
    Right(i32),
}
}

is rendered on the C side as a tagged union via Either_i32_Tag (the discriminant) and Either_i32 (the outer struct). All members of a family travel together; see “Placement” below.

Placement

In bundled mode the question is moot: every monomorphization ends up in the single output header. In partitioned mode cheadergen has to pick one of (potentially) several headers as the owner.

The rule is: a monomorphized instantiation lives in the consuming crate’s header, where “consumer” means the crate whose non-generic type or extern "C" item references the instantiation.

ScenarioWhere Wrapper_i32 lives
Target A has extern "C" fn foo(w: Wrapper<i32>)A.h
Non-target B has struct BStruct { field: Wrapper<i32> }, used by target AB.h (the earliest consumer in the dependency chain)
Both B and A use Wrapper<i32> directlyBoth, with #ifndef guards (see below)

Why not the defining crate?

The “obvious” choice (put Wrapper_i32 in the header of the crate that defines Wrapper<T>) can result in synthetic includes.

If Wrapper<MyStruct> lived in wrapper.h, then wrapper.h would need #include "mystruct.h" even though the defining crate has no Rust dependency on MyStruct’s crate.

Placing monomorphizations in the consuming crate keeps the include graph faithful to the Rust dependency graph.

Duplicate-definition guards

When multiple consumers use the same instantiation, cheadergen emits the definition in each consuming crate’s header. To prevent redefinition errors when those headers are included together, every monomorphized type is wrapped in #ifndef guards:

#ifndef WRAPPER_I32_DEFINED
#define WRAPPER_I32_DEFINED
typedef struct {
    int32_t value;
} Wrapper_i32;
#endif /* WRAPPER_I32_DEFINED */

A C preprocessor sees the first definition, sets the guard, and skips all subsequent copies. Duplicates are harmless.

Single-header runs skip the guards. If only one header is being written in this invocation, cheadergen omits the #ifndef wrappers — they would just be noise.

Non-generic types are not guarded

Non-generic types live in their defining crate’s header and appear there exactly once, so guards aren’t needed and would just clutter the output.

See also

Anatomy of a generated header

Every header cheadergen writes follows the same fixed section order. This page walks through that order top-to-bottom and points at the cheadergen.toml options that influence each section.

Knowing the structure helps when reading a generated header and if you’re trying to customise the output.

Options like preamble, [header.<name>].include_guard, and [package.<name>] live in cheadergen.toml. See Global configuration for how the file is discovered and loaded; this page focuses on which section each option affects.

The sections, in order

#SectionConfigured by
1Preamble (verbatim text)preamble
2Include guard or #pragma once[header.<name>].include_guard, pragma_once
3Autogen warningautogen_warning
4Standard <...> includesno_includes, usize_is_size_t (adds <stddef.h>)
5User includesincludes
6Dependency-crate #includespartitioned mode only (automatic)
7After-includes (verbatim text)after_includes
8CHEADERGEN_ALIGNED(n) macroemitted automatically if any type has #[repr(C, align(N))]
9Type definitions (structs, unions, enums, typedefs) and their associated constantsdocumentation, documentation_style, documentation_length, style, sort_by, [package.<name>] types, usize_is_size_t
10Free const items as #define macros[constant].sort_by, constants opt-in flags
11extern "C" { … } open bracecpp_compat (C-only)
12static declarations[static].sort_by
13Function declarations[fn].sort_by
14extern "C" close bracecpp_compat
15Include guard closeinclude_guard
16Trailer (verbatim text)trailer

Where this lives in the code. The emission order is defined in cheadergen_cli/src/codegen.rs (generate_c_header). If a discrepancy ever creeps in between this page and the source, the source wins — please open an issue.

A worked example

Given this Rust crate:

#![allow(unused)]
fn main() {
#[repr(C)]
pub struct Foo {}

impl Foo {
    #[cheadergen::config(export)]
    pub const U8: u8 = 1;
    #[cheadergen::config(export)]
    pub const GREETING: &str = "hello world";
}

#[unsafe(no_mangle)]
pub extern "C" fn root(x: Foo) {}
}

cheadergen generate -l c -o include produces:

/* [3] WARNING: this file was auto-generated by cheadergen from `demo`. Do not edit it manually. */

/* [4] Standard includes */
#include <stdarg.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdlib.h>

/* [9] Type definition, with its associated constants attached */
typedef struct {

} Foo;
#define Foo_U8 1
#define Foo_GREETING "hello world"

/* [13] Function declaration */
void root(Foo x);

(The bracketed numbers correspond to the section table above; cheadergen itself doesn’t emit them.)

Notes on individual sections

Preamble & trailer (1, 16)

Verbatim text. Useful for license blocks, project-wide banners, or compiler pragmas that aren’t part of cheadergen’s vocabulary. Both are simple string-substitution, cheadergen does not parse what you put in them.

Include guard vs #pragma once (2, 15)

If [header.<name>].include_guard = "FOO_H" is set, you get an #ifndef/#define/#endif triple wrapping the file. If pragma_once = true is set, you get #pragma once. The two are independent, you can use both or neither.

Autogen warning (3)

By default cheadergen synthesizes a warning that names the crate the header came from (workspace-relative path for workspace members, name@version otherwise). Set autogen_warning = "" to suppress it entirely; set it to a string to override the default.

Standard includes (4)

cheadergen always emits <stdarg.h>, <stdbool.h>, <stdint.h>, and <stdlib.h> unless you set no_includes = true. <stddef.h> is added only when any item in the header was lowered with usize_is_size_t = true (because that flag introduces size_t and ptrdiff_t).

Dependency includes (6)

In partitioned mode the dependency crate that defines a referenced type gets its own header file, and the consuming header gets a matching #include "dep.h". You don’t need to configure this manually in cheadergen.toml, cheadergen computes the set of includes based on actual type usage.

Type definitions (9)

The heart of the header. Each #[repr(C)] struct/enum/union (or anything forced in via #[cheadergen::config(export)]) becomes a definition here. Associated consts attached to a type are emitted immediately after the type they belong to as #defines.

A topological sort guarantees that by-value type dependencies are defined before their users; for ties, sort_by controls the secondary order.

The extern "C" block (11, 14)

C++ consumers need an extern "C" block to disable name mangling. cheadergen emits one around functions and statics when the cpp_compat option (or --cpp-compat CLI flag) is set on a C header.

Per-header configuration

Sections 1–16 can all be tuned globally or per individual output header via the [header.<name>] section in cheadergen.toml. CLI flags (such as --cpp-compat, --bundle) trump both. The full override priority is documented in the config reference.

Bundled vs partitioned output

cheadergen has two output modes: bundled and partitioned

Bundled emits a self-contained header for a single target package, with every reachable dependency type inlined. It only handles one target per run.

Partitioned (the default) generates a C header for each defining crate, with cross-crate references wired up automatically via #include directives. Use partitioned when you have multiple target packages, or when you want shared types to live in their own header.

This page explains the model, the trade-offs, and the knobs you can turn.

The two modes at a glance

BundledPartitioned
CLI flag--bundle(default)
Targets per runExactly oneOne or more
Files producedA single combined headerOne header per defining crate
Dependency typesInlined into the outputDefined once, included from consumers
Best forSingle-crate libraries that ship a single header artifactWorkspaces with shared FFI types

Bundled mode

Bundled mode handles one target package per run. Pass --bundle (or set bundle = true in cheadergen.toml) with exactly one target package and cheadergen produces a single self-contained header. Every type the target references (whether it’s defined in the target itself, in a workspace member, or in a crates.io dependency) is inlined into that output file.

If you select more than one target package with --bundle, cheadergen errors out: two bundles built from overlapping dependency graphs would duplicate the shared types in a way C can’t tolerate, and the tool refuses to produce colliding output silently.

This is the right choice when:

  • You ship exactly one C-facing library.
  • You want a single artifact your downstream consumers can drop into their build with no questions.
  • You’re migrating from cbindgen and want output shape parity (cbindgen’s defaults match this).

Partitioned mode

Without --bundle, cheadergen treats types as belonging to their defining crate and produces one header per crate that contributes types. Cross-crate references are wired up automatically:

  • If target crate A uses BType by value, A.h gets #include "b.h" and B.h gets the full definition of BType (and any other types A actually uses from B).
  • Crates that contribute only types (no extern "C" of their own) get a “types-only” header, with no function declarations.
  • Types from crates the target doesn’t actually use don’t appear at all. Partitioning is demand-driven.

Include vs forward-declare

The decision is per dependency crate, not per type:

  • If any type from crate B is used by-value in A.h, A.h emits #include "b.h".
  • If every B type used in A.h is referenced only behind a pointer, A.h emits a forward declaration (typedef struct BType BType;) and no #include.

This rule keeps the include graph shallow: cheadergen never both includes and forward-declares from the same dependency.

Opaque packages stay inline

When

[package.<name>] 
types = "opaque"`

is used, the package’s types are forward-declared in each consuming header. No header file is generated for the opaque package.

Skipped packages don’t generate anything

When

[package.<name>] 
types = "skip"`

is used, cheadergen emits no header for the package and assumes you’ll supply your own (typically via includes in cheadergen.toml). Use this for packages whose C headers come from somewhere outside cheadergen’s control.

Generic types in partitioned mode

Wrapper<i32> doesn’t belong to the crate that defines Wrapper<T>, it belongs to whichever crate consumes the instantiation. This rule avoids synthetic cross-crate include dependencies and circular includes. See Generics and monomorphization for the full treatment.

The partitioned-only CLI flags

These flags only apply when generating partitioned output (cheadergen rejects them in combination with --bundle):

--prune-orphans

After generation, delete any *.h / *.hpp files in --output-dir that cheadergen didn’t write in this run. Useful in CI to catch stale headers from removed types or packages. Without --prune-orphans, those files just sit there.

--skip-empty

Skip writing headers that would otherwise contain no declarations (no types, constants, statics, or functions). Combined with --prune-orphans this ensures empty-by-deletion headers are cleaned up rather than rewritten as empty stubs.

Choosing between modes

Start with partitioned (the default). Switch to bundled if:

  • You’re packaging a single shared library and the downstream contract is “one header, one .so”.
  • The crate has no workspace siblings that would also need their own headers.
  • You’re migrating an existing cbindgen pipeline and want a small diff against the original output.

Stay on partitioned when:

  • You have multiple FFI-facing crates that share types and would otherwise duplicate them.
  • You want changes to a shared type to flow through one canonical header rather than appearing in N independent copies.
  • You’re building a workspace-wide SDK where consumers may pick and choose which headers they need.

Comparison with cbindgen

cbindgen is the established tool for generating C/C++ headers from Rust. cheadergen is a younger alternative, built on different foundations.

This page explains where they differ and how to decide which one fits your project.

Why a a new tool?

cbindgen parses your Rust source files to infer the shape of the C API for your project. It’s an approach with quite a few downsides:

  • Limited module resolution and type-level analysis.
  • No macro expansion without resorting to cargo expand or ad-hoc code paths.
  • Visibility into other crates is shallow.

In other words, cbindgen can’t tap into the rich view that’s available to the Rust compiler for a given crate. This limitation can create significant friction in real world projects; enough friction to build an alternative, cheadergen!

cheadergen relies on the Rust compiler for compile-time reflection, via rustdoc-json. That gives it module resolution, type checking, accurate cross-crate references, and macro expansion for free.

But it’d be dishonest to state that cheadergen is a clear-cut improvement for all projects: there are some downsides and limitations you should be aware of before choosing one or the other for your next project.

Behavioural differences

Annotation syntax

cbindgen uses doc-comment annotations:

#![allow(unused)]
fn main() {
/// cbindgen:rename-all=ScreamingSnakeCase
#[repr(C)]
pub enum Status { Ok, Error }
}

cheadergen uses proc-macro attributes that participate in normal Rust compilation:

#![allow(unused)]
fn main() {
#[cheadergen::config(rename_all = "SCREAMING_SNAKE_CASE")]
#[repr(C)]
pub enum Status { Ok, Error }
}

cheadergen downsides

#[cfg] attributes

cbindgen translates #[cfg(...)] into #ifdef ... directives so a single header works across platforms. cheadergen can’t do this (see Limitations).

Constants in array lengths

cbindgen preserves named constants in array-length positions (int32_t x[FOO]). cheadergen receives the array length as a resolved literal value from rustdoc JSON, so it emits int32_t x[10] and loses the constant’s name in that position only.

Doc generation

cheadergen must generate JSON documentation for all packages that appear in the C API exposed by your Rust project. This implies a cargo doc invocation that, in most cases, is going to be slower than parsing source code via syn (the approach taken by cbindgen).

The slowdown is mitigated via a disk-based cache for third-party crates, but it’s worth calling out as a downside compared to cbindgen.

Furthermore, this requires you have to a nightly toolchain installed, at least on dev machines. This issue may go away in the future, once rustdoc-json stabilizes.

When you’d pick cbindgen

  • You need #[cfg]-to-#ifdef translation (Firefox-style multi-platform headers).
  • You need C++-idiomatic output (enum class, custom constructors, derive-ostream, etc.).
  • You need Cython output
  • You can’t use a nightly toolchain for build-time tools.

When you’d pick cheadergen

  • You’re on a Cargo workspace with multiple FFI-facing crates and want partitioned headers that share types coherently.
  • You value type-checked, IDE-friendly annotations over doc-comment strings.
  • Your platform set is narrow enough that the missing #[cfg] handling isn’t a blocker.

If you’re using cbindgen on a project, and you want to migrate over, check out our migration guide.

Limitations

cheadergen is young project: there may be defects that we have yet to uncover. Furthermore, it has some structural limitations due to the approach it has chosen for its reflection engine.

It’s good to be aware of these limitations ahead of choosing cheadergen for your next project.

No #[cfg] awareness

cheadergen’s type analysis uses rustdoc-json as its primary source. rustdoc only emits items for the current compilation target. Items behind a #[cfg] predicate that evaluates to false for a given rustdoc invocation simply won’t appear in the generated JSON: cheadergen won’t see them, at all.

Let’s look at an example:

#![allow(unused)]
fn main() {
#[cfg(target_os = "linux")]
#[unsafe(no_mangle)]
pub extern "C" fn linux_specific() { /* ... */ }

#[cfg(target_os = "windows")]
#[unsafe(no_mangle)]
pub extern "C" fn windows_specific() { /* ... */ }
}

When you run cheadergen on macOS, neither function appears in the generated header. Run on Linux and only linux_specific appears. Run on Windows and only windows_specific appears.

If your bindings need to span multiple platforms today, you can must use a workaround: run cheadergen once per target platform and post-process the outputs into a combined header yourself.

It’s not great, but it’s the best we can suggest at the moment. If your project is #[cfg]-heavy, prefer cbindgen for now.

Constants names may be lost in translation

cheadergen may omit named constants when generating C headers. For example, in this case:

#![allow(unused)]
fn main() {
pub const FOO: usize = 10;

#[repr(C)]
struct Foo {
    x: [i32; FOO],
}
}

cheadergen receives the array length from rustdoc JSON as the literal string "10", so it emits int32_t x[10] and the named FOO constant is no longer used to express the array length.

No C++ native headers

cheadergen can generate a C++-compatible C header via cheadergen generate --cpp-compat [...], but the output is a C-shaped header that happens to compile as C++. There are no C++-idiomatic enums, no operator overloads, no custom constructors. See C++ support for the full picture and the roadmap.

See also

C++ support

cheadergen is, as of today, a C header generator. C++ projects can still consume cheadergen’s output, but only as C header(s) included from C++, not as a native C++ header with C++-idiomatic features.

What works today: --cpp-compat

The CLI flag is --cpp-compat (equivalently, [c].cpp_compat = true in cheadergen.toml). It tells cheadergen to wrap function and static declarations in an extern "C" block guarded by #ifdef __cplusplus:

#ifdef __cplusplus
extern "C" {
#endif // __cplusplus

double distance(Point a, Point b);

#ifdef __cplusplus
}  // extern "C"
#endif  // __cplusplus

That single addition is enough to make the generated .h file safe to #include from C++ source code. The C++ compiler now knows the declarations inside use the C calling convention and C name mangling (none), so linking against your Rust library “just works.”

The same header is still valid C: the #ifdef guards mean a C compiler sees the declarations directly.

What’s not (yet) supported: native C++ output

cheadergen cannot produce .hpp files with C++-idiomatic constructs (e.g. enum class, namespaces, templates, destructors, etc.).

Native C++ support is a feature gap and may be addressed in a future release. There is no scheduled timeline; if you’re interested in it, the issue tracker is the right place to express the use case or contribute.

In the meantime, --cpp-compat is the recommended path for any project that needs to consume cheadergen output from C++.

See also

Integrate with Cargo

This guide shows how to make cheadergen part of your normal Cargo workflow, so generated headers stay in sync with your Rust source.

Run cheadergen outside cargo build

cheadergen must be invoked as a standalone CLI command, not from a Cargo build.rs script.

The reason is a Cargo deadlock: a build.rs runs while Cargo holds an exclusive lock on the target directory for the in-flight build. cheadergen internally shells out to cargo +<nightly> rustdoc -- --output-format=json, which tries to acquire the same lock — and waits forever for the build that spawned it to finish. There’s no way around this from inside build.rs.

The right place for header generation is therefore one level up, in a command runner (just, make, nix, your CI workflow, etc.) that sequences cheadergen and cargo build as separate steps.

A minimal justfile:

# Regenerate C headers
headers:
    cheadergen generate --output-dir include --prune-orphans

# Build the crate, refreshing headers first
build: headers
    cargo build --release

A minimal Makefile:

.PHONY: headers build

headers:
	cheadergen generate --output-dir include --prune-orphans

build: headers
	cargo build --release

Either way: cheadergen finishes (and releases the target-dir lock) before cargo build starts.

Idempotent output: mtimes won’t churn

cheadergen calls write_header_if_changed for every output file: if the new content is byte-identical to what’s already on disk, the file is left alone and its mtime is preserved.

Build systems that watch generated headers by mtime (Make, CMake, downstream cc crates) won’t see spurious rebuilds just because cheadergen ran with no real change.

Picking an output directory

There’s no universal convention; pick what works for your project.

It’s common to use a directory in the crate root (e.g. /include), committed alongside the Rust source. Easy for downstream C consumers: they -I path/to/crate/include.

If you’re using partitioned mode, pass --prune-orphans to keep the output directory clean as types are added and removed.

Generate headers for a Cargo workspace

cheadergen was designed for Cargo workspaces with multiple FFI-facing crates that share types. In partitioned mode (the default) the right shape falls out automatically: each crate that contributes types gets its own header, and consuming crates #include it.

This guide shows the common patterns. For the mental model of which crates participate in a run and why, see Target packages.

Select multiple target packages

Pass --package multiple times to generate headers for several workspace members in one invocation:

cheadergen generate \
    --output-dir include \
    --package alpha \
    --package beta

Each --package argument names a workspace member that should produce a “target” header (one with extern "C" function declarations, not just types).

Alternatively, omit --package and use --input-dir to target every library member under one or more workspace directories:

cheadergen generate \
    --output-dir include \
    --input-dir crates/alpha \
    --input-dir crates/beta

Pass --input-dir multiple times to combine several directories. Each path must live inside the workspace rooted at the current directory.

Omitting both --package and --input-dir falls back to a Cargo-style default: if the current directory sits inside a workspace member it targets just that member, otherwise it targets the workspace’s default-members (every member, for a virtual workspace). See Target packages for the full rule.

What gets generated

Given two targets alpha and beta that both use a Config type from a workspace dependency core:

include/
├── alpha.h     # target — extern "C" functions and types unique to alpha
├── beta.h      # target — extern "C" functions and types unique to beta
└── core.h      # types-only — Config and other shared types
  • Both alpha.h and beta.h start with #include "core.h".
  • Config is defined exactly once, in core.h.
  • core.h has no extern "C" block — core isn’t a target, just a type contributor.

A non-target dependency only gets a header if at least one target actually uses its types. External crates (from crates.io) follow the same rules.

Customising header filenames

By default a crate named my-crate produces my_crate.h (hyphens become underscores). Override the base name per package with header_name:

# cheadergen.toml
[package."core"]
header_name = "myproject_core"

Now alpha.h and beta.h will #include "myproject_core.h" and the file on disk is renamed to match.

If a dependency name is ambiguous (multiple versions present), disambiguate with name@version:

[package."internal-state@1"]
header_name = "state_v1"

Pruning stale output

Add --prune-orphans to clean up any *.h files in the output directory that this run didn’t write. Useful when you remove types or packages:

cheadergen generate \
    --output-dir include \
    --prune-orphans \
    -p alpha \
    -p beta

Without --prune-orphans, removing a crate from the workspace leaves its header behind as silent stale output.

--prune-orphans only acts on top-level files matching the language extension (*.h for C); it never touches subdirectories or unrelated files.

Skipping empty headers

Some non-target crates contribute no concrete C-visible types, only generic definitions that get monomorphized into their consumers’ headers. By default cheadergen still writes an (empty) header file for those crates. Pass --skip-empty to suppress the empty files:

cheadergen generate \
    --output-dir include \
    --skip-empty \
    --prune-orphans

--skip-empty is partitioned-only (it’s rejected with --bundle). Combining it with --prune-orphans ensures empty-by-deletion headers also get cleaned up.

See also

Manage the rustdoc cache

cheadergen invokes cargo +<nightly> doc --output-format=json under the hood, which is the slowest step of the whole pipeline. To keep iteration fast, cheadergen keeps the resulting JSON in a per-crate on-disk cache and reuses it on subsequent runs.

You normally don’t have to touch the cach, it builds up incrementally as you run cheadergen generate. This page documents the cheadergen cache subcommands for the cases where you do.

Inspect the cache location

cheadergen cache show dir

Prints the absolute path to the cache directory and exits. The location is platform-specific.

Use this in CI configs to feed the cache directory into your runner’s cache action.

Pre-warm the cache

cheadergen cache warm                                # Cargo-style default
cheadergen cache warm --input-dir crates/alpha       # one workspace directory
cheadergen cache warm --input-dir a --input-dir b    # several workspace directories
cheadergen cache warm -p alpha -p beta               # specific workspace members by name

cache warm issues a single batched cargo doc -p crate1 -p crate2 ... invocation, maximising parallelism within cargo.

Clear the cache

cheadergen cache clear

Removes the cache directory and exits.

In practice you rarely need this. Reasons you might:

  • The cache directory has grown large enough to matter on a developer machine and you want a fresh start.
  • You’re chasing a reproducibility bug and want to confirm the issue isn’t a stale cache entry.

See also

Migrate from cbindgen

This guide walks through moving an existing cbindgen-using crate to cheadergen. Before you start, read Comparison with cbindgen and Limitations so you know what features won’t carry over.

1. Install cheadergen

See Install cheadergen.

2. Translate your cbindgen.toml

cheadergen config translate \
    --from cbindgen.toml \
    --to cheadergen.toml

The translator covers the option set cheadergen supports. Any cbindgen-only options are reported and need a manual decision.

A spot-check, before:

# cbindgen.toml
language = "C"
include_guard = "MYLIB_H"
pragma_once = true

[parse]
parse_deps = true

after:

# cheadergen.toml
pragma_once = true

[header."mylib"]
include_guard = "MYLIB_H"

cheadergen doesn’t have an equivalent of parse_deps because the rustdoc pipeline always sees dependency types when they’re reachable from extern "C" items — the option simply isn’t needed.

3. Rewrite cbindgen annotations

cbindgen reads doc-comment annotations like /// cbindgen:rename-all=...; cheadergen uses proc-macro attributes. Sweep your source and translate them using this table:

cbindgen (doc comment)cheadergen (proc-macro attr)
/// cbindgen:no-export#[cheadergen::config(skip)]
/// cbindgen:ignore#[cheadergen::config(skip)]
/// cbindgen:rename-all=ScreamingSnakeCase#[cheadergen::config(rename_all = "SCREAMING_SNAKE_CASE")]
/// cbindgen:rename-all=SnakeCase#[cheadergen::config(rename_all = "snake_case")]
/// cbindgen:rename-all=CamelCase#[cheadergen::config(rename_all = "camelCase")]
/// cbindgen:field-names=[x, y]#[cheadergen::config(field_names(x, y))]
/// cbindgen:prefix-with-name#[cheadergen::config(prefix_with_name)]
/// cbindgen:bitfield (on a field)#[cheadergen(bitfield = N)]

You’ll also need to add the crate dependency:

# Cargo.toml
[dependencies]
cheadergen = "0.2"

The proc-macro lives in the cheadergen crate; you don’t need to depend on cheadergen_cli (that’s the CLI binary).

Tip. The proc-macro attributes are type-checked at compile time. If you write #[cheadergen::config(field_namse(x, y))] (note the typo), cargo build will reject it. cbindgen’s doc-comment annotations would silently do nothing.

4. Regenerate and diff

cheadergen generate --output-dir include --bundle

Pass --bundle to match cbindgen’s “single file per crate” shape. Once you have output, diff it against your committed cbindgen header:

diff -u old-include/mylib.h include/mylib.h

Expect differences in these places:

  • #[cfg]-gated items. cheadergen sees only the items reachable for the current build target; cbindgen translates them to #ifdefs. There is no way to make these match — see Limitations.
  • Array length constants. [i32; FOO] becomes int32_t x[FOO] in cbindgen and int32_t x[10] in cheadergen.
  • Whitespace. Both tools format their output, but the rules differ; small whitespace diffs are normal.
  • Doc-comment formatting. Doxygen-style vs plain C-comment choices are controlled by documentation_style.
  • Item ordering may differ between cheadergen and cbindgen.

If anything else looks unexpectedly different, open an issue with the input and a diff.

5. Decide bundled vs partitioned

--bundle is the closest match to cbindgen’s behaviour. Once the bundled output is right, consider whether partitioned mode would suit your project better:

  • Workspaces with multiple FFI-facing crates almost always benefit.
  • Single-crate libraries usually don’t.

You can switch at any time; the only difference is the CLI flag (or the bundle setting in cheadergen.toml).

6. Wire it into your build

If your project ran cbindgen from a build.rs, you’ll need to relocate the invocation: cheadergen can’t run inside build.rs (it deadlocks against Cargo’s target-dir lock). Move header generation into a command runner that sequences cheadergen before cargo build; see Integrate with Cargo for the recipe.

Common migration pitfalls

  • You forgot to add cheadergen to [dependencies]. Without it, the #[cheadergen::config(...)] attribute fails to resolve. Errors surface during cargo build.
  • You translated cbindgen.toml but never deleted it. Both tools cheerfully ignore the other’s config. Remove cbindgen.toml (or rename it to cbindgen.toml.bak until you’re confident) to avoid confusion.
  • The required nightly isn’t installed. Follow the installation instructions in the error returned by cheadergen generate to install the missing toolchain.

Commit or regenerate? Managing generated headers

A perennial question for any code generator: should the generated artifacts live in your repository, or should every consumer rebuild them on demand?

cheadergen works fine either way. This guide lays out the two viable workflows, what each costs, and what each gives you.

Option 1: commit the generated headers

The headers live in your repo next to the Rust source. A CI check guarantees they stay in sync.

Choose this when:

  • You ship a stable C API that downstream consumers depend on.
  • Reviewers should see C API changes in pull requests.
  • Downstream C consumers don’t (and shouldn’t have to) install cheadergen or a Rust nightly.

The workflow:

  1. Commit the contents of include/ (or wherever your headers live) to version control.

  2. Add a CI job that regenerates the headers and fails if git diff is non-empty:

    cheadergen generate --output-dir include --prune-orphans
    git diff --exit-code include/
    

    --prune-orphans is important: without it, removing a type from the Rust source leaves the old header behind, and CI passes when it shouldn’t.

  3. Document the regeneration command in CONTRIBUTING.md so contributors can run it locally before opening a PR.

Pros:

  • C API changes show up in code review as +/- lines in real headers.
  • Consumers can git clone and immediately have a usable C interface.
  • No nightly toolchain on the consumer side.
  • Works inside restricted CI / build environments that can’t install cheadergen.

Cons:

  • More churn in PR diffs.
  • Easy to forget to regenerate. A CI check to prevent drift is highly recommended.

Option 2: generate on demand

The headers are a build artifact, never committed. Each consumer runs cheadergen as part of their build.

Choose this when:

  • The C API is internal to your team and you control all the consumers.
  • The C API isn’t stable yet; reducing PR churn is more valuable than visible C API diffs.
  • All consumers already have cheadergen + a nightly toolchain.

The workflow:

  • Wire cheadergen generate into a command runner (just, make, etc.) so it runs as a step before/after cargo build—see Integrate with Cargo for the reason this can’t live inside a build.rs.
  • Add include/ (or wherever the generated headers land) to .gitignore.

Pros:

  • Smaller PR diffs.
  • No risk of source and generated headers drifting apart, they’re regenerated every build.

Cons:

  • Every consumer needs cheadergen + nightly Rust on their build machine.
  • C API changes aren’t visible in code review without extra tooling.
  • First-time setup is slower for new contributors.

Hybrid: commit, but only at release time

A workable middle ground for some libraries: don’t commit headers on every PR, but stamp a regenerated set into the release tarball or GitHub release artifact at tag time. Contributors don’t deal with header diffs, but downstream consumers still get headers without needing cheadergen.

The hook is simply a step in your release workflow that runs cheadergen generate ... and includes the output in the published artifact.