A C++20 + Rust library that loads a C++ header at runtime, uses Clang as the language frontend, projects serializable declarations into a Rust schema, and exposes generic JSON serde-style serialization/deserialization to a C++ application through FFI.
The example application converts a live C++ Company object to a JSON string and rebuilds a new live object from that same string. No per-type serialization code is written by hand: Clang parses the header, C++20 aggregate reflection walks the object generically, and Rust maps positional data to named JSON with serde.
The C++ side never names a field and never writes a type-specific encoder. It only knows how many members an aggregate has and how to visit them in order; the Rust schema supplies every name and every enum spelling.
- Serialize. C++ reflection writes the object as a positional payload: members in declaration order, enums as their underlying integers,
std::vector<T>as a JSON array. - Name it. Rust receives that payload plus the compiler-inferred type name, walks the Clang-derived schema, and emits named JSON with enums rendered as member names.
- Deserialize. Rust converts named JSON back into the positional payload, and C++ reflection materializes a new live object of the requested type.
C++ reflection Rust schema + serde
Company ----------------> ["TechCorp",[[1,"Alice",true,0]]] ----------------> {"name":"TechCorp","employees":[{"id":1,...,"role":"Admin"}]}
Company <---------------- ["TechCorp",[[1,"Alice",true,0]]] <---------------- {"name":"TechCorp","employees":[{"id":1,...,"role":"Admin"}]}
The positional payload is the wire format between the two languages; the named JSON is what the application sees.
- Parses the full C++20 grammar, preprocessing, and type system through the installed Clang frontend
- Generates and consumes Clang AST JSON instead of attempting to parse C++ syntax with regular expressions
- Filters declarations to the requested header while handling Clang's elided source locations
- Preserves namespaces and nested scopes as qualified names
- Extracts structs/classes, fields, base classes, enums, typedefs, and type aliases
- Preserves field spellings for pointers, references, arrays, templates, canonical types, and bitfields
- Exposes a generic Rust-backed serialize/deserialize API to C++ via a C ABI
- Rebuilds live C++ objects from JSON generically, with no per-type adapter, macro, or registration code
- Allows a C++ app to query a schema and validate JSON for mapped types, including enum-typed struct fields
- Supports compact or pretty JSON through an
indentargument - Uses RAII wrappers in C++ to release Rust schema and string allocations safely
- Reports schema and conversion failures as C++ exceptions carrying the Rust error message
- main.cpp: C++ console app that initializes the Rust library and calls the schema/serialization API
- example.h: example C++ header used for demonstration
- cpp_rs_serde.h: RAII schema facade, generic positional codec, and Rust-string ownership
- cpp_rs_reflect.h: C++20 aggregate reflection (field counting and field visiting)
- src/lib.rs: Rust library root and module export
- src/schema.rs: schema representation and validation logic
- src/clang_parser.rs: wrapper around Clang AST generation
- src/ffi.rs: C ABI entry points used by the C++ app
- src/ast_builder.rs: AST JSON-to-schema extraction logic
Requirements:
- A C++20 compiler
- Clang available as
clangfor runtime header parsing - Rust and Cargo
- CMake 3.10 or newer
cmake -S . -B out/build/'GCC 13.3.0 x86_64-linux-gnu'
cmake --build out/build/'GCC 13.3.0 x86_64-linux-gnu'CMake builds the Rust cdylib through Cargo before compiling and linking the C++ executable.
./out/build/'GCC 13.3.0 x86_64-linux-gnu'/cpprustserThe demo prints the extracted schema, serializes a live Company, edits the JSON text, rebuilds a new Company from it, and deserializes a standalone Person through the same generic code path:
[2] Serialized Company:
{
"employees": [
{ "active": true, "id": 1, "name": "Alice", "role": "Admin" },
...
],
"name": "TechCorp"
}
[3] Replaced Bob with Robert in the JSON string
[4] Restored Company: TechCorp (3 employees)
- 1 Alice active=true role=0
- 2 Robert active=false role=1
- 3 Charlie active=true role=2
[5] Restored Person: Dana role=2
.vscode/launch.json provides a cppdbg/gdb configuration whose preLaunchTask runs the cmake: build task from .vscode/tasks.json. Press F5 to build the Rust cdylib, link the executable with debug symbols, and start stepping. Breakpoints in src/ffi.rs also resolve, because the cdylib is a debug build.
Compiling main.cpp on its own does not link, because the cpp_rs_* symbols live in the Rust library. Always build through CMake.
cargo testUnit tests cover schema extraction from Clang AST JSON and the positional/named wire conversions. Integration tests in tests/clang_ast_parser.rs run the real Clang frontend over example.h and tests/fixtures/cpp20_features.hpp.
The C ABI uses clang, C++20, and the compiler's default include paths. Rust callers can configure the executable, language standard, include directories, defines, target, sysroot, and other project flags:
use cpprustser_lib::clang_parser::{parse_header_with_clang_options, ClangOptions};
let options = ClangOptions {
executable: "clang++".into(),
standard: "c++20".into(),
extra_args: vec![
"-I/path/to/project/include".into(),
"-DMY_FEATURE=1".into(),
],
};
let schema = parse_header_with_clang_options("include/model.hpp", &options)?;Clang diagnostics are returned as an error when the header cannot be parsed with the supplied compilation settings.
The sample header in example.h contains a Role enum, a Person, and a Company with a vector of employees:
#include <string>
#include <vector>
enum class Role {
Admin = 0,
User = 1,
Guest = 2
};
struct Person {
int id;
std::string name;
bool active;
Role role;
};
struct Company {
std::string name;
std::vector<Person> employees;
};On the JSON wire, a Person represents the enum member by name:
{"id":42,"name":"Alice","active":true,"role":"User"}Known struct and enum field types are validated recursively. For example, "role": "User" is accepted, while an enum name not declared by Role is rejected.
Company can be serialized with an array of employee objects:
{
"name": "Acme",
"employees": [
{"id": 42, "name": "Alice", "active": true, "role": "Admin"},
{"id": 7, "name": "Bob", "active": false, "role": "User"}
]
}std::vector<Person> elements are recursively converted as Person on both directions of the wire.
The cpp_rs::Schema facade owns the Rust schema handle, infers C++ schema type names, and moves objects to and from JSON. Callers do not manage raw FFI allocations, write adapters, or provide handwritten JSON:
#include "cpp_rs_serde.h"
#include "example.h"
cpp_rs::Schema schema("/absolute/path/to/example.h");
Company company {
"TechCorp",
{
{1, "Alice", true, Role::Admin},
{2, "Bob", false, Role::User}
}
};
const std::string json = schema.serialize(company, true); // live object -> JSON string
const Company restored = schema.deserialize<Company>(json); // JSON string -> new live object
const Person person = schema.deserialize<Person>(R"({"id":42,"name":"Dana","active":true,"role":"Guest"})");The same two calls work for every struct in the parsed header. Adding a new type requires no C++ code at all: declare it in the header and Clang, the schema, and reflection handle the rest.
schema.schema_json()returns the extracted schema for inspection. It is optional and is not required before serialization or deserialization.schema.serialize(value, indent)converts a live C++ value to named JSON.schema.deserialize<T>(json)converts named JSON into a new live C++ object of typeT.schema.normalize<T>(json, indent)validates named JSON as the inferred schema typeTand returns its canonical form.schema.native_handle()exposes the underlying handle only for direct C ABI interoperability.
The final indent argument controls output formatting for the JSON-returning API:
const std::string compact = schema.serialize(company, false);
const std::string pretty = schema.normalize<Company>(compact, true);falsereturns compact JSON on one line.truereturns pretty JSON with indentation and newlines.
Reflection covers aggregates with up to 16 fields, std::string, bool, integers, floating-point types, enums, std::vector<T>, and any nesting of those. Non-aggregate classes, pointers, and unsupported members fail at compile time with a clear static_assert.
No serialization code is needed. Declare the type in the header:
struct Address {
std::string city;
int zip;
};and it works immediately:
const Address address{"Haifa", 3100000};
const std::string json = schema.serialize(address, true);
const Address restored = schema.deserialize<Address>(json);Clang picks up the declaration, the Rust schema supplies the field names and enum spellings, and cpp_rs_reflect.h walks the members. Field order in the header is the contract between the two sides; renaming a field changes the JSON key with no code change.
extern "C" {
void *cpp_rs_init(const char *path);
char *cpp_rs_schema_json(void *schema);
char *cpp_rs_serialize(
void *schema,
const char *type_name,
const char *json_value,
bool indent);
char *cpp_rs_deserialize(
void *schema,
const char *type_name,
const char *json_value,
bool indent);
char *cpp_rs_normalize(
void *schema,
const char *type_name,
const char *json_value,
bool indent);
char *cpp_rs_last_error();
void cpp_rs_free_schema(void *schema);
void cpp_rs_free_string(char *ptr);
}cpp_rs_serialize takes the positional payload and returns named JSON. cpp_rs_deserialize takes named JSON and returns the positional payload. Both return nullptr on failure, with the reason available from cpp_rs_last_error.
cpp_rs_initreturns a Rust allocation that must be released withcpp_rs_free_schema.cpp_rs_schema_json,cpp_rs_serialize,cpp_rs_deserialize,cpp_rs_normalize, andcpp_rs_last_errorreturn Rust strings that must be released withcpp_rs_free_string.cpp_rs::Schemaandcpp_rs::RustStringrelease these allocations automatically on normal returns, early returns, and C++ exceptions.- Input pointers remain owned by C++ and only need to stay valid for the duration of each call.
- Struct input must be a JSON object containing every extracted field.
- Known direct struct and enum fields are validated recursively.
- Enum values may be declared names or matching numeric values.
- Unknown schema types, missing fields, field-count mismatches, and invalid enum values return
nullptrthrough the C ABI and raise a C++ exception carrying the Rust error message. - Sequence containers such as
std::vector<Person>are converted element by element using the element type from the schema.
Clang is the full C++20 parser; the Rust Schema is intentionally a serialization-oriented projection of that AST. It does not claim that every valid C++ construct has a direct JSON representation. Functions, constructors, access control behavior, template definitions, constraints, macros, object layout, pointer ownership, and arbitrary expressions are parsed by Clang but are not converted into serde models.
Known nested struct and enum fields are validated recursively. Other field types retain their exact Clang spelling and are passed through as JSON values. Applications that need strict validation for containers, pointers, inherited fields, variants, or project-specific wrappers should add explicit mapping policies rather than inferring ownership or wire formats from C++ types.
The C++ reflection layer relies on aggregate initialization and structured bindings, so it covers plain data types only. Types with user-declared constructors, private members, or base classes are not reflectable and are rejected at compile time rather than silently mis-encoded.