Skip to content

Repository files navigation

cpprustser

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.

How the round trip works

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.

  1. 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.
  2. 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.
  3. 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.

What it does

  • 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 indent argument
  • 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

Project layout

  • 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

Build

Requirements:

  • A C++20 compiler
  • Clang available as clang for 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.

Run

./out/build/'GCC 13.3.0 x86_64-linux-gnu'/cpprustser

The 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

Debug

.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.

Test

cargo test

Unit 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.

Clang configuration

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.

Example header

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.

Typed C++ API

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 methods

  • 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 type T.
  • schema.normalize<T>(json, indent) validates named JSON as the inferred schema type T and 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);
  • false returns compact JSON on one line.
  • true returns pretty JSON with indentation and newlines.

Supported types

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.

Adding a new type

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.

C ABI

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.

Ownership

  • cpp_rs_init returns a Rust allocation that must be released with cpp_rs_free_schema.
  • cpp_rs_schema_json, cpp_rs_serialize, cpp_rs_deserialize, cpp_rs_normalize, and cpp_rs_last_error return Rust strings that must be released with cpp_rs_free_string.
  • cpp_rs::Schema and cpp_rs::RustString release 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.

Validation behavior

  • 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 nullptr through 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.

Notes

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.

About

Generic Rust library for serialize / deserialize objects from C++

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages