Introduction
Cpp2Rust translates C++ to fully safe Rust automatically. It is a syntax-driven translator based on clang’s AST.
Cpp2Rust’s algorithm is described in the paper Cpp2Rust: Automatic Translation of C++ to Safe Rust published at PLDI 2026.
Overview
Cpp2Rust first parses the input C++ file(s) with clang and produces an AST. It
then traverses the AST and emits Rust code as strings, inserting calls to the
libcc2rs runtime library where needed (e.g., for raw pointer semantics).
Finally, the Rust code is pretty-printed using rustfmt to a single .rs file.
By default the reference counting model is used, which produces fully safe
Rust. A generator of unsafe Rust is also available through the --model=unsafe
command line argument for debugging and performance comparisons.
Runtime library (libcc2rs)
The generated code relies on a runtime library designed to simplify the
translation process. C pointers are converted into the Ptr<T> type provided by
libcc2rs. Ptr<T> models C pointer semantics, including null, arithmetic, and
aliasing, while satisfying Rust’s borrow checker through checked run-time
operations.
Building
Requirements
On Ubuntu, install the required dependencies with:
sudo apt install libclang-22-dev clang++-22 ninja-build cmake
pip install ruff==0.15.22
Build
mkdir build
cd build
cmake -GNinja ..
ninja
ninja check
Usage
Translate a single file
./build/cpp2rust/cpp2rust --file=<file>.cpp -o=<file>.rs
By default, the reference counting model is used (fully safe output). To generate unsafe Rust instead:
./build/cpp2rust/cpp2rust --file=<file>.cpp -o=<file>.rs --model=unsafe
Minimal example. Given hello.cpp:
#include <cstdio>
int main() {
printf("hello world\n");
return 0;
}
Running ./build/cpp2rust/cpp2rust --file=hello.cpp -o=hello.rs produces:
pub fn main() {
std::process::exit(main_0());
}
fn main_0() -> i32 {
println!("hello world");
return 0;
}
Compile and run with:
rustc hello.rs -L ../libcc2rs/target/debug
./hello
Translate a whole program
First generate a
compile_commands.json
for your project. With CMake this is one extra flag:
cmake -DCMAKE_EXPORT_COMPILE_COMMANDS=ON ..
Then run:
./build/cpp2rust/cpp2rust --dir=<dir> -o <output>.rs
<dir> must be the directory that contains compile_commands.json.
Test Suite
# Run all tests
ninja check
# Run only the unit tests
ninja check-unit
# Run libcc2rs unit tests
ninja check-libcc2rs
# Run libcc2rs-macros unit tests
ninja check-libcc2rs-macros
# Regenerate expected output for unit tests after intentional changes
REPLACE_EXPECTED=1 ninja check-unit
Overview
Translation rules describe how C++ library APIs are mapped to Rust. Each rule
module lives in the rules/ directory and pairs a C++ source file (src.cpp)
with its Rust translation for each model (tgt_unsafe.rs and
tgt_refcount.rs).
Every rule is expressed as ordinary, compilable C++ and Rust source code, free
of anything platform dependent. Both sides are run through real compilers at
build time, so a rule that does not compile fails the build, and the
platform-specific spellings (for example bool canonicalizing to _Bool) are
derived by the compiler on the host rather than written by hand. The same rule
sources work on every platform cpp2rust builds on.
Rules go through a build-time compilation pipeline before cpp2rust can use
them:
- You author a rule module: C++ patterns in
src.cppand Rust targets intgt_unsafe.rs/tgt_refcount.rs. - At build time, two preprocessors compile the module into Rules IR under
<build>/rules/<module>/:cpp-rule-preprocessorcompiles the C++ side intoir_src.json, andrule-preprocessorcompiles the Rust side intoir_unsafe.jsonandir_refcount.json. - At startup,
cpp2rustloads the Rules IR files and indexes the rules by the canonical signature of the C++ construct they match.
The rest of this part covers each stage:
- Rule Format: the files that make up a rule module and how the two models are layered.
- Writing Rules: how to write rules for functions, methods, operators, types, constants, and variadics.
- Compat Shims: how macro-based libc APIs like
errnoandFD_SETare rewritten into matchable function calls. - Conventions: naming and style conventions rule authors must follow.
- The Rule Preprocessors: the two build-time tools that compile rules to the Rules IR.
- The Rules IR: the JSON format the preprocessors emit.
- Loading and Matching: how
cpp2rustloads the Rules IR and matches rules against the input AST. - The Matching Engine: how a candidate rule’s signature is unified against the input.
- Rule Rewriting: how rule bodies are adapted at application
time, in particular the
with_mutrewrite.
Rule Format
A rule module is a directory under rules/, usually named after the header or
library it covers (rules/unistd, rules/vector, rules/string, …). It
contains:
src.cppand/orsrc.c: the C++ (or C) side of each rule.tgt_unsafe.rs: the Rust targets for the unsafe model.tgt_refcount.rs: the Rust targets for the reference counting model (optional, see below).
A rule is a pair of same-named functions on the two sides. Names determine the rule kind:
f1,f2, … are expression rules: they map a C++ call, member access, constructor, or constant to a Rust expression.t1,t2, … are type rules: they map a C++ type to a Rust type.
Expression rules
On the C++ side, an fN function must have a body that is exactly one return
statement. The returned expression is the pattern: the preprocessor resolves the
callee of that expression (the function, method, constructor, enum constant,
or macro being used) and that becomes the rule’s matching key. The function
parameters stand for the arguments at the call site.
// rules/unistd/src.cpp
int f4(const char *pathname) { return unlink(pathname); }
On the Rust side, the same-named function gives the replacement expression.
Parameters must be named a0, a1, … and correspond positionally to the C++
parameters:
#![allow(unused)]
fn main() {
// rules/unistd/tgt_unsafe.rs
unsafe fn f4(a0: *const libc::c_char) -> i32 {
libc::unlink(a0)
}
}
#![allow(unused)]
fn main() {
// rules/unistd/tgt_refcount.rs
fn f4(a0: Ptr<u8>) -> i32 {
match nix::unistd::unlink(a0.to_rust_string().as_str()) {
Ok(()) => 0,
Err(__e) => {
libcc2rs::cpp2rust_errno().write(__e as i32);
-1
}
}
}
}
When the converter encounters unlink(x) in the input, it emits the rule body
with the translated x substituted for a0.
Type rules
On the C++ side, a tN rule is a type alias (using or typedef). On the Rust
side, it is a zero-argument function whose return type is the mapped Rust type
and whose body is the default initializer for that type:
// rules/vector/src.cpp
template <typename T1> using t1 = std::vector<T1>;
#![allow(unused)]
fn main() {
// rules/vector/tgt_unsafe.rs
fn t1<T1>() -> Vec<T1> {
Vec::new()
}
}
Model layering
The loader always reads ir_unsafe.json first. When translating with the
reference counting model, it then overlays ir_refcount.json on top: entries
with the same rule name replace the unsafe ones.
This means tgt_refcount.rs only needs to contain the rules that differ from
the unsafe model. For example, the __builtin_mul_overflow rule in
rules/builtin has a pointer out-parameter (a2 below), so the two models
translate it differently: in the unsafe model a2 is a raw *mut i64 written
through a deref, while in the refcount model it is a Ptr<i64> written through
Ptr::write. The other two arguments are identical in both models:
#![allow(unused)]
fn main() {
// rules/builtin/tgt_unsafe.rs
unsafe fn f9(a0: i64, a1: i64, a2: *mut i64) -> bool {
let (val, ovf) = a0.overflowing_mul(a1);
*a2 = val;
ovf
}
}
#![allow(unused)]
fn main() {
// rules/builtin/tgt_refcount.rs
fn f9(a0: i64, a1: i64, a2: Ptr<i64>) -> bool {
let (val, ovf) = a0.overflowing_mul(a1);
a2.write(val);
ovf
}
}
The module’s other rules (byte swaps, __builtin_expect, …) translate
identically in both models, so they appear only in tgt_unsafe.rs and the
refcount model inherits them. A module where no rule needs a refcount-specific
translation can omit tgt_refcount.rs entirely.
C and C++ sources
A module may have both src.c and src.cpp; both are preprocessed and merged
into one ir_src.json. Defining the same rule name in both files is a hard
error, so numbering must not collide.
This split is necessary because rules match on the exact canonical signature of
the callee, and some libc functions have different signatures in C and C++.
For example, C has a single char *strchr(const char *, int), while C++
replaces it with const-correct overloads such as
const char *strchr(const char *, int). Since the signatures differ,
rules/cstring defines one rule per language:
// rules/cstring/src.cpp
const char *f6(const char *a0, int a1) { return strchr(a0, a1); }
// rules/cstring/src.c
char *f5(const char *a0, int a1) { return (strchr)(a0, a1); }
The C++ rule matches strchr calls in code translated as C++, the C rule
matches them in code translated as C.
rules/builtin uses this to cover both languages: src.cpp defines f9/f10
for the C++ __builtin_mul_overflow (returning bool) while src.c defines
f12/f13 for the C version (returning int); their Rust bodies are
identical.
The rules crate
The whole rules/ tree is a single Rust crate. rules/build.rs walks the tree,
collects every tgt_*.rs, and generates rules/src/modules.rs with one
#[path = ...] module per file. Building the crate therefore type-checks every
rule body against the crates the rule targets call into, which are declared as
dependencies in rules/Cargo.toml (libcc2rs, libc, nix, …). The Rust
rule preprocessor compiles exactly this crate to resolve types in rule bodies.
rules/src/ is the only subdirectory that is not a rule module.
Writing Rules
This page shows how to write rules for each kind of C++ construct. In every case
the recipe is the same: write an fN (or tN) function on the C++ side whose
single return statement exercises the construct, and a same-named function on
the Rust side giving the translation.
Free functions
// rules/stat/src.cpp
int f1(const char *pathname, struct stat *statbuf) {
return stat(pathname, statbuf);
}
#![allow(unused)]
fn main() {
// rules/stat/tgt_refcount.rs
fn f1(a0: Ptr<u8>, a1: Ptr<Stat>) -> i32 {
match nix::sys::stat::stat(a0.to_rust_string().as_str()) {
Ok(__s) => {
a1.with_mut(|__st| *__st = Stat::from_libc(&__s));
0
}
Err(__e) => {
libcc2rs::cpp2rust_errno().write(__e as i32);
-1
}
}
}
}
Rule bodies may be arbitrarily complex; multi-statement bodies are wrapped in a block when spliced into the output.
return statements are prohibited in Rust rule bodies (the preprocessor rejects
them); produce the result as a tail expression instead. The body is not emitted
as a function of its own: it is spliced inline into the generated code as a
block expression, so a return would not end the rule, it would return from
whatever generated function the rule happens to be expanded in.
When the pattern’s type cannot be named, the rule uses an auto return type:
rules/iomanip writes auto f1(int n) { return std::setw(n); } because
std::setw returns an unspecified type.
Methods
There is no special syntax for member functions: write a free function that
takes the receiver as its first parameter and calls the method on it. On the
Rust side the receiver is a0.
// rules/vector/src.cpp
template <typename T1> std::size_t f2(const std::vector<T1> &o) {
return o.size();
}
#![allow(unused)]
fn main() {
// rules/vector/tgt_unsafe.rs
unsafe fn f2<T1>(a0: Vec<T1>) -> usize {
a0.len()
}
}
Template rules use generic parameters named T1, T2, … on both sides,
matched positionally. The rule is written against the open template
std::vector<T1>, with T1 left as a placeholder, so a single rule covers
every instantiation: when the input program calls size() on, say, a
std::vector<int>, the matcher binds T1 = int.
Static member functions
A static member function is also written with a receiver parameter, which exists
only to name the class. The call site has no receiver argument, so the Rust side
drops it and numbers the remaining parameters from a0; here there are none:
// rules/limits/src.cpp
template <typename T1> T1 f1(std::numeric_limits<T1> &a0) { return a0.max(); }
#![allow(unused)]
fn main() {
// rules/limits/tgt_unsafe.rs
unsafe fn f1<T1: HasMinMax>() -> T1 {
<T1>::MAX
}
}
(HasMinMax is a helper trait defined alongside the rules in the same file.)
Constructors
Constructors are functions returning the type by value, one rule per overload:
// rules/string/src.cpp
std::string f7(const char *s, std::size_t n) { return std::string(s, n); }
std::string f9(std::size_t n, char ch) { return std::string(n, ch); }
Overloads that differ in value category are distinct rules too: rules/vector
has separate rules for push_back(const T1 &) and push_back(T1 &&).
No destructor rules exist so far: the STL and libc APIs covered by the current
rules have not needed any, since their types map to Rust types whose Drop
implementations already do the right thing.
Operators
Write operators with explicit operator call syntax, in member form
(x.operator@(...)) or free form (operator@(a, b)):
// rules/map/src.cpp
template <typename T1, typename T2>
T2 &f1(std::map<T1, T2> &o, const T1 &key) { return o.operator[](key); }
template <typename T1, typename T2>
bool f11(typename std::map<T1, T2>::iterator a,
typename std::map<T1, T2>::iterator b) {
return operator!=(a, b);
}
Post-increment is distinguished from pre-increment by the usual dummy int
parameter: a0.operator++(a1) versus it.operator++(). Conversion operators
use the same explicit syntax: a0.operator T1 &() in rules/functional matches
the conversion of a std::reference_wrapper<T1> back to a reference. Field
accesses are rules of their own, matched by the field: it->first and
it->second through iterators, plain o.second on a pair (rules/map,
rules/pair).
Callable arguments
A rule parameter may be a callable. Function pointers are spelled directly; for
a lambda, whose type cannot be written, the rule declares a file-scope lambda
and takes decltype(lambda):
// rules/algorithm/src.cpp
auto lambda = [](const T2 &a, const T2 &b) { return false; };
void f6(T1 first, T1 last, decltype(lambda) comp) {
return std::stable_sort(first, last, comp);
}
#![allow(unused)]
fn main() {
// rules/algorithm/tgt_unsafe.rs
unsafe fn f6<T1: Ord, T2>(a0: *mut T1, a1: *mut T1, a2: &mut T2)
where
T2: FnMut(&T1, &T1) -> bool,
{ ... }
}
T1 and T2 are not template parameters here but file-scope helper structs
modelling an iterator and its value type; being named like generics, they bind
as T1/T2 at the use site. The function pointer version of the comparator is
a separate rule (f7).
Iterators
There is no iterator abstraction: an iterator type gets a type rule, and every
operation on it its own expression rule (operator*, operator++,
operator!=, …). What the type maps to is up to the rule:
std::string::iterator becomes a plain pointer (*mut libc::c_char unsafe,
Ptr<u8> refcount), while std::map iterators become the runtime types
libcc2rs::UnsafeMapIterator/MapIterator. Dependent iterator types are named
with typename:
// rules/map/src.cpp
template <typename T1, typename T2>
using t2 = typename std::map<T1, T2>::const_iterator;
Types
A type rule has two halves. On the C++ side, declare a type alias named tN for
the C++ type being mapped. On the Rust side, write a function with the same name
that takes no arguments: its return type is the Rust type that the C++ type
maps to, and its body is the default value the generated code uses when it
needs to construct one (e.g. for an uninitialized variable). Reference and
pointer variants of a type each get their own rule:
// rules/iostream/src.cpp
using t1 = std::ostream;
using t2 = std::ostream &;
using t3 = std::ostream *;
C structs use typedef instead of using:
// rules/stat/src.cpp
typedef struct stat t1;
#![allow(unused)]
fn main() {
// rules/stat/tgt_unsafe.rs
fn t1() -> ::libc::stat { unsafe { std::mem::zeroed() } }
}
#![allow(unused)]
fn main() {
// rules/stat/tgt_refcount.rs
fn t1() -> libcc2rs::Stat { Default::default() }
}
A type rule may map to the sentinel type libcc2rs::IgnoreRule, meaning “this
model has no special mapping for the type”; the converter then falls back to its
normal type conversion. This is useful when only one model needs a custom
mapping: rules/carray maps multi-dimensional C arrays to nested boxed slices
in the refcount model, while its tgt_unsafe.rs targets are IgnoreRule so the
unsafe model keeps the default array conversion.
Enum values, constants, and macros
Constants are fN functions that take no arguments and return the constant, one
rule per value:
// rules/fcntl/src.cpp
int f3(void) { return O_CREAT; }
int f4(void) { return O_TRUNC; }
#![allow(unused)]
fn main() {
// rules/fcntl/tgt_unsafe.rs
unsafe fn f3() -> i32 { ::libc::O_CREAT }
}
For macros that expand to integer literals, the preprocessor records the macro
name rather than the value, so O_CREAT in the input matches this rule by
name. Enum constants and global variables (e.g. std::cout) are matched by
their qualified name. A global and its address are separate rules:
rules/iostream maps both std::cout (f1) and &std::cout (f3).
Integer-literal macros are the only macros matchable directly. Macros whose
expansions are platform internals with no stable callee, such as errno or
FD_SET, are first rewritten into calls to synthetic cpp2rust_* functions by
the compat shims; rules then match the shim call.
Variadic functions
The C++ side uses a template parameter pack rather than a C-style ...
parameter, out of necessity: a function that takes ... cannot forward its
variadic arguments to another call, so a rule like
int f1(int a0, int a1, ...) { return fcntl(a0, a1, ...); }
is not expressible. A parameter pack can be forwarded (args...), which is
exactly what the rule body needs to do. The Rust side takes a trailing parameter
that must be typed &[VaArg] and named va:
// rules/fcntl/src.cpp
template <typename... Args>
int f1(int a0, int a1, Args... args) {
return fcntl(a0, a1, args...);
}
#![allow(unused)]
fn main() {
// rules/fcntl/tgt_refcount.rs
fn f1(a0: i32, a1: i32, va: &[VaArg]) -> i32 { ... }
}
Bodies read the arguments through the va-args API in libcc2rs (VaArg,
VaList, the VaArgGet accessors, format_c).
Passthrough rules
When a call should be forwarded verbatim to the same-named function in Rust’s
libc crate, the Rust target can be an extern declaration instead of a body:
// rules/fcntl/src.cpp
template <typename... Args>
int f1(int a0, int a1, Args... args) {
return fcntl(a0, a1, args...);
}
#![allow(unused)]
fn main() {
// rules/fcntl/tgt_unsafe.rs
unsafe extern "C" {
fn f1(a0: i32, a1: i32, ...) -> i32;
}
}
The converter then emits a direct libc::fcntl(...) call at the call site.
Platform-specific rules
Gate the C++ side with the usual preprocessor conditionals and the Rust side
with #[cfg(...)]; the two must agree so that the rule name sets line up:
// rules/socket/src.c
#ifdef __linux__
int f4(void) { return SOCK_CLOEXEC; }
#endif
#![allow(unused)]
fn main() {
// rules/socket/tgt_unsafe.rs
#[cfg(target_os = "linux")]
unsafe fn f4() -> i32 {
libc::SOCK_CLOEXEC
}
}
The Rust preprocessor evaluates #[cfg] attributes against the host target
(only target_os = linux|macos and target_arch = x86_64|x86 are accepted) and
drops non-matching rules.
Mutually exclusive platform branches use #elif with disjoint rule numbers:
rules/errno defines f91 to f135 under __linux__ and f136 to f153
under __APPLE__. Feature-test macros a pattern needs must come before the
includes, as with #define _GNU_SOURCE in rules/socket/src.c.
Pattern resolution limits
The preprocessor resolves a template pattern by instantiating its template parameters with synthesized types (The Rule Preprocessors):
- A bare
T1becomes an empty struct, so the pattern cannot use members, operators, or nested types ofT1. - A parameter pack instantiates to the empty pack.
- A non-type parameter is pinned to the value
1.
Unqualified callees are looked up in namespace std first and in the global
scope only when std has no match, so an unqualified name that exists in both
resolves to the std one.
Compat Shims
Rule matching needs a resolvable callee. The preprocessor keys every expression
rule on the function, method, constructor, constant, or global that the
pattern’s return expression resolves to, and the only macros it can record are
those that
expand to an integer literal,
which match by macro name. Any other macro is invisible to the rule system: by
the time clang has built the AST, the macro is gone and only its expansion
remains.
That is a problem for a small set of libc APIs that are specified as macros over platform internals:
errnois an object-like macro; glibc expands it to(*__errno_location()), macOS to(*__error()).assertexpands to a conditional that stringifies the condition and calls a platform-specific failure handler with file and line arguments.FD_SET,FD_CLR,FD_ISSET, andFD_ZEROexpand to bit manipulation on thefd_setrepresentation, through helpers that differ per platform.ntohl,ntohs,htonl, andhtonsexpand to byte-swap builtins or to nothing at all, depending on endianness.
There is no stable, platform-independent callee here to key a rule on. The
compat headers in cpp2rust/compat/ fix this by rewriting each such macro
into a call to a synthetic, well-known function before matching happens.
How the shims work
cpp2rust/compat is injected as a system include directory ahead of the
platform headers in every clang invocation the project makes: both when
cpp2rust parses the input program and when cpp-rule-preprocessor compiles
rule sources. The shared flag list lives in cpp2rust/compat/platform_flags.h
(getPlatformClangBeginFlags), and the directory path is baked in at build time
via the COMPAT_INCLUDE_DIR definition.
A shim header sits at the same relative path as the real header it shadows
(errno.h, sys/select.h, arpa/inet.h, …), so an ordinary
#include <errno.h> finds the shim first. The header then:
- pulls in the real platform header with
#include_next(a GNU extension; the shared flags pass-Wno-gnu-include-nextfor it), #undefs the macro,- declares a
cpp2rust_*shim function, - redefines the macro to call the shim.
cpp2rust/compat/errno.h in full:
#include_next <errno.h>
#undef errno
int *cpp2rust_errno(void);
#define errno (*cpp2rust_errno())
The redefinition keeps errno an lvalue by dereferencing the returned pointer,
so both reads and assignments like errno = 0 still parse; what the matcher
sees in either case is a call to int *cpp2rust_errno().
Because the input program and the rule sources are compiled with the same shim headers, both sides canonicalize to the same signature, and an ordinary expression rule matches it:
// rules/errno/src.c
#include <errno.h>
int *f1(void) { return cpp2rust_errno(); }
The shim functions are declared but never defined on the C side. They only exist so that the callee resolves; translation replaces the call with the rule body, so no C implementation is ever linked. Whatever the shim is supposed to do is supplied by the Rust targets:
#![allow(unused)]
fn main() {
// rules/errno/tgt_unsafe.rs
unsafe fn f1() -> *mut i32 {
libcc2rs::cpp2rust_errno_unsafe()
}
}
#![allow(unused)]
fn main() {
// rules/errno/tgt_refcount.rs
fn f1() -> Ptr<i32> {
libcc2rs::cpp2rust_errno()
}
}
In the unsafe model libcc2rs::cpp2rust_errno_unsafe wraps the real platform
errno location (__errno_location on Linux, __error on macOS). The refcount
model instead virtualizes errno as a thread-local Value<i32> inside
libcc2rs; this is the same cell that other refcount rules write when they
translate a failing libc call into
libcc2rs::cpp2rust_errno().write(__e as i32).
A rule pattern may spell either the macro or the shim directly; the two are
identical after expansion. rules/errno and rules/assert call the shim by
name, while rules/arpa_inet and rules/select write the macro form:
// rules/select/src.cpp
void f2(int fd, fd_set *set) { return FD_SET(fd, set); }
The current shims
| Header | Macros | Shim functions | Rules |
|---|---|---|---|
assert.h | assert | cpp2rust_assert_fail(bool) | rules/assert maps it to assert!(a0) |
errno.h | errno | cpp2rust_errno() | rules/errno, see above |
arpa/inet.h | ntohl, ntohs, htonl, htons | cpp2rust_ntohl(x), … | rules/arpa_inet maps them to u32::from_be, u16::to_be, … |
sys/select.h | FD_SET, FD_CLR, FD_ISSET, FD_ZERO | cpp2rust_fd_set(fd, set), … | rules/select maps them to libc::FD_SET(...) (unsafe) or CFdSet methods (refcount) |
Note how the shim also normalizes the shape of the API. C’s assert is a
macro precisely so it can stringify its condition and capture file and line; the
shim reduces it to a plain void(bool) function, and the Rust side regains the
diagnostics by mapping it to the assert! macro.
Adding a new shim
To make another macro-based API matchable:
- Create the header in
cpp2rust/compat/at the same relative path as the platform header that defines the macro. - Follow the pattern above:
#include_nextthe real header,#undefthe macro, declare acpp2rust_<name>function with the macro’s effective signature, and redefine the macro to call it. - Write rules for the shim in a
rules/module as for any other function, including the corresponding header insrc.c/src.cpp. - If a model needs runtime support (as refcount errno does), implement it in
libcc2rsand call it from the rule target.
Keep the shim’s signature platform-independent; the whole point is that both sides of every rule see one canonical declaration on every platform.
Related normalization
The same shared flag list also passes -D_FORTIFY_SOURCE=0, which keeps glibc
from substituting fortified variants (__printf_chk and friends) for standard
calls. Like the shims, this ensures that calls in the input program resolve to
the standard declarations the rules are written against.
Conventions
Most of these conventions are enforced by the preprocessors, and violating them fails the build; the notes below call out the ones that are not checked.
Naming
| Element | C++ side | Rust side |
|---|---|---|
| Expression rule | f1, f2, … | same name |
| Type rule | t1, t2, … via using/typedef | fn tN() -> RustType with no arguments |
| Parameters | free-form (o, it, key, dst, n, …) | must be a0, a1, … consecutive from 0 |
| Generics | T1, T2, … (type and non-type params) | T1, T2, … consecutive from 1 |
| Variadic pack | typename... Args | trailing va: &[VaArg] |
| Locals in Rust bodies | double-underscore prefix: __v, __fd, __e, … |
Notes:
- Rule numbering is per module, and gaps are currently allowed (e.g.
rules/maphas nof4), though this might change in the future. Names must be unique acrosssrc.candsrc.cppcombined. - On the C++ side parameter names are free, but the order defines the
placeholder indices: the first parameter is
a0on the Rust side, the second isa1, and so on. The receiver of a method rule is always the first parameter, hencea0. - Generic parameters are matched positionally between the two sides, so
T1in the Rust target means “whatever bound toT1in the C++ pattern”. - Locals introduced inside Rust rule bodies use a
__prefix. This is not checked by the build, but it is needed: rule bodies are spliced inline into the generated code, so an unprefixed local could collide with a variable name from the translated program.
Function qualifiers
- In
tgt_unsafe.rs, expression rules areunsafe fn; type rules (tN) are plainfn. - In
tgt_refcount.rs, all rules are safefn. The refcount model produces fully safe Rust, so a refcount rule body must not needunsafe.
The build does not check the qualifiers themselves; only rustc’s usual rules
apply when the rules crate compiles. In particular, nothing stops an unsafe
block inside a refcount rule body from being spliced into the output, so keeping
refcount rules safe is what upholds the model’s safety guarantee.
C++ pattern shape
- An
fNbody must be exactly onereturnstatement. The preprocessor rejects anything else. returnstatements are not allowed inside Rust rule bodies; write the result as a tail expression instead.- Exercise exactly one construct per rule. If an API has several overloads,
write one rule per overload (including separate rules for
const T &versusT &¶meters).
Argument accesses
Every use of an aN parameter in a rule body is classified as a read, write, or
move by the rule preprocessor. Passing
an argument by value counts as a read, not a move; the only way to record a move
is std::mem::take(&mut aN).
Type checking
All tgt_*.rs files are compiled as part of the rules crate, so a rule body
that does not type-check against libcc2rs, libc, nix, etc. breaks the
build. If a rule needs a new crate dependency, add it to rules/Cargo.toml and
to the hardcoded crate list in rule-preprocessor/src/semantic.rs (see
The Rule Preprocessors).
The Rule Preprocessors
Two build-time tools compile rule modules into the Rules IR that
cpp2rust loads at runtime. Both write into <build>/rules/<module>/:
cpp-rule-preprocessorcompilessrc.cpp/src.cintoir_src.json.rule-preprocessorcompilestgt_unsafe.rs/tgt_refcount.rsintoir_unsafe.json/ir_refcount.json.
The C++ side is keyed by resolved callee signatures; the Rust side by rule
names. The two are joined by rule name when cpp2rust loads them.
cpp-rule-preprocessor
A clang LibTooling executable (cpp2rust/cpp_rule_preprocessor.cpp) that runs
once per rule directory:
cpp-rule-preprocessor --dir rules/string --out <build>/rules/string/ir_src.json
Extra compiler flags can be passed with repeated --cxxflags options, though
CMake, which invokes the tool for every rule module via the
preprocess-cpp-rules target, passes none. Note that the parent directory of
--out must already exist; CMake creates it before each invocation, so a manual
run must do the same.
Rule sources are always compiled with the fixed flag set from
cpp2rust/compat/platform_flags.h, the same one used to parse input programs
(see Compat Shims). There is no compilation database and no
-std= flag: the language is chosen by clang from the file extension, and
src.c is processed before src.cpp.
For each rule it:
- Validates that every
fNbody is exactly onereturnstatement. - Resolves the callee of the returned expression. For non-template rules this is just the called declaration. For template rules the callee is unresolved, so the tool instantiates the rule’s template parameters with synthesized dummy types and runs overload resolution to find the function the rule refers to.
- Prints the resolved declaration as a canonical signature string:
<return type> <qualified::name>(<param types>[, ...])[ const][ volatile][ &|&&], where, ...appears for C-variadic functions and the trailing qualifiers only for methods. FortNaliases it prints the underlying type.
The output is a flat JSON object mapping rule names to these signature strings.
The printer preserves typedef sugar instead of desugaring it: size_t prints as
size_t, not unsigned long, which is what lets it map to usize while plain
unsigned long maps to u64 (for tN aliases this preservation is explicit;
inside function signatures the spelling survives through the printing policy).
Integer literals expanded from a macro are recorded as the macro name, which
is how constant rules
like the O_CREAT one match by name.
rule-preprocessor
A Rust binary crate built with the nightly toolchain because it links the
compiler’s own libraries (rustc_driver, rustc_middle, …). It processes the
whole rules tree in one invocation:
CARGO_TARGET_DIR=<target> cargo +nightly run --release \
--manifest-path rule-preprocessor/Cargo.toml -- <build>/rules [rules-dir]
The environment is load-bearing:
-
CARGO_TARGET_DIRmust be set (the tool aborts otherwise): the rlibs of the rule dependencies (libcc2rs,libc,nix, …) are looked up in$CARGO_TARGET_DIR/<profile>/deps, which thecargo runabove populates. The crate list is hardcoded, so a new dependency inrules/Cargo.tomlalso needs an entry inrule-preprocessor/src/semantic.rs.[!WARNING] Stale rlibs from an earlier build can be picked up silently. Run
ninja cleanto fix this. -
The sysroot comes from running
rustc --print=sysroot, so therustconPATHmust be the same nightly the preprocessor was built with (running throughcargo +nightly runguarantees this). -
rules-diris optional and defaults to the relative path../rules, resolved against the current working directory of the process.
CMake drives all of this via the preprocess-rust-rules target: it first builds
the rules crate with the stable toolchain (which also regenerates
rules/src/modules.rs), then runs the preprocessor with
CARGO_TARGET_DIR=<build>/target_preprocessor. That initial cargo build of
the rules crate is what actually gates the build on rule bodies type-checking
(see below). The preprocessor works in two phases.
Phase 1, syntactic. Each tgt_*.rs file is parsed with rust-analyzer’s
parser, and functions whose #[cfg] does not match the host are dropped. Every
function body is then turned into a list of fragments, whose kinds are
described in The Rules IR.
The fragmentation is mainly concerned with how the rule’s arguments are used:
references to parameters and generics become placeholder and generic fragments,
while source text that does not involve an argument is kept as-is.
Each placeholder is tagged with an access: read, write, or move. Some uses
give the access away syntactically (&mut a0 is a write); those that do not,
typically method-call receivers and arguments, are left as unknown for
phase 2. This phase also applies the two
preprocessor-side rewrites that
support rule rewriting.
Phase 2, semantic. The preprocessor compiles the rules crate in-process
with rustc and walks the typed HIR. This gives it the real signature of every
callee, which resolves the unknown accesses: passing to a &mut/*mut
parameter is a write, to a &/*const parameter a read, and to
std::mem::take a move. For type rules it also records which of the nine
derivable standard traits (Copy, Clone, Debug, Default, PartialEq,
Eq, PartialOrd, Ord, Hash) the mapped type implements. A placeholder
still unknown after this phase fails the build.
The preprocessor assumes the rules crate is buildable, which the earlier
cargo build of the crate ensures; errors from the in-process compilation are
therefore only reported as a warning.
The result is one ir_<model>.json per input file, keyed by rule name. The
output file name is derived from the input file name (tgt_unsafe.rs becomes
ir_unsafe.json) and the module directory is the direct parent of the
tgt_*.rs file.
The Rules IR
Each rule module compiles to up to three JSON files in
<build>/rules/<module>/:
ir_src.json: the C++ side, fromcpp-rule-preprocessor.ir_unsafe.json: the Rust side for the unsafe model, fromrule-preprocessor.ir_refcount.json: the Rust side for the refcount model, also fromrule-preprocessor(only if the module has atgt_refcount.rs).
All three are objects keyed by rule name (f1, t1, …), and the loader joins
them by name.
Source IR (ir_src.json)
A flat map from rule name to the canonical signature of the C++ construct the
rule matches. For rules/vector:
{
"t1": "std::vector<T1>",
"f3": "_Bool std::vector<T1>::empty() const"
}
This signature string is the lookup key for the whole rule: the converter prints C++ constructs from the input AST with the same printer and compares the strings.
Target IR (ir_unsafe.json / ir_refcount.json)
An expression rule serializes as an ExprRule object: the rule’s signature plus
its body as a list of fragments. For
unsafe fn f6<T1>(a0: &mut Vec<T1>) -> *mut T1 { a0.as_mut_ptr() }:
"f6": {
"body": [
{ "method_call": {
"receiver": [ { "placeholder": { "arg": 0, "access": "read" } } ],
"body": [ { "text": ".as_mut_ptr()" } ] } }
],
"generics": { "T1": [] },
"params": { "a0": { "type": "&mut Vec<T1>" } },
"return_type": { "type": "*mut T1", "is_unsafe_pointer": true }
}
The fragment kinds are:
text: literal Rust source, emitted verbatim.placeholder: a use of one of the rule’saNparameters in the body (not an argument of whatever the body calls); the converter substitutes the translated call-site argument here. Its fields:arg: the parameter index N.access: how the body uses the argument:read,write, ormove.is_index_base: the placeholder is the base of an index expression.
generic: aTNslot, replaced with the instantiated Rust type; serialized as the 1-based index N.method_call: a method call split intoreceiverandbodyfragment lists, so the code generator can rewrite the pair (see Rule Rewriting).va_args: the expansion point for a variadic tail.
Every type in the Rules IR (in params, return_type, and type rules) is a
TypeInfo object, the type text plus a set of flags:
is_refcount_pointer: the type is aPtr<...>.is_unsafe_pointer: the type is a raw*mut/*constpointer.derives(type rules only): the standard traits the mapped type implements (Copy,Clone,Default, …).
The two pointer flags are mutually exclusive; the loader rejects a type with both set.
An ExprRule carries two flags of its own:
multi_statement: the body has more than one statement, or a statement followed by a tail expression, and must be wrapped in a block to stay a single expression.is_extern: the rule is an extern passthrough declaration and has no body.
Fields that are false, empty, or unset are omitted from the Rules IR. A va
parameter is never listed in params, and a () return type is omitted.
A type rule serializes as a TypeRule object: its TypeInfo plus the init
initializer expression, merged into one object:
"t1": { "type": "Vec<T1>", "init": "Default::default()" }
There is no explicit tag distinguishing the two rule kinds: an entry with body
is an expression rule, one with type and init a type rule.
In-memory form
cpp2rust mirrors the Rules IR in C++ structs of the same names, defined in
cpp2rust/converter/translation_rule.h.
TranslationRule::Load reads one module directory (the ir_*.json files
described above) and produces two maps keyed by rule name, one holding
ExprRules and one holding TypeRules:
- An
ExprRuleholds the body fragments, the parameter and returnTypeInfos, and the two rule-level flags (multi_statementandis_extern). The name-keyed Rules IR maps become positional vectors: parameteraNis entry N ofparams, genericTNis entry N-1 ofgenerics(each entry being the bound list). Rules support at most 9 generic parameters (kMaxGenerics). - A
TypeRuleholds the mapped type’sTypeInfoand theinitializerexpression. The same struct also represents the built-in type mappings (scalars, pointers, …) that the loader registers directly in code, without any Rules IR behind them: for example,intmaps toi32, andint *to*mut i32in the unsafe model orPtr<i32>in the refcount model.
Both also carry src, the canonical C++ signature attached from
ir_src.json; it is the key the rule is matched by.
How the loader finds the Rules IR directory, overlays the refcount model on the unsafe one, and indexes the loaded rules for matching is covered in Loading and Matching.
Loading and Matching
Finding the rules directory
cpp2rust takes the Rules IR directory via --rules <dir>. If the flag is
omitted it tries ./rules and then <executable dir>/../rules, accepting the
first candidate that contains (recursively) a subdirectory with ir_src.json
plus ir_unsafe.json or ir_refcount.json. Since the build writes the Rules IR
to <build>/rules and the binary lands in <build>/bin, the default resolution
picks up the generated Rules IR without any flags.
Loading
Rules are loaded once per process by Mapper::LoadTranslationRules:
- Built-in type mappings are registered first. Every scalar is mapped with its
width taken from the host (
intmaps toi32,unsigned longtou64), together with itsconstform and its pointer forms:*mut/*constin the unsafe model,Ptr<T>in the refcount model, where constness is dropped.charmaps tolibc::c_charin the unsafe model and tou8in refcount;size_t/ssize_tmap tousize/isize;void *maps to*mut ::libc::c_void, or in refcount toAnyPtr. - Every subdirectory of the rules directory is loaded with
TranslationRule::Load, which readsir_unsafe.json, overlaysir_refcount.jsonwhen translating with the refcount model, and then attaches the C++ signature fromir_src.jsonto each rule by name.
Loading is strict: an ir_src.json entry with no matching target rule is a
fatal error (this is what catches mismatched #if/#[cfg] gating), every
generic declared by a rule must appear in its C++ signature, and two type rules
for the same C++ type are rejected.
Matching
Loaded rules are indexed in two multimaps, one for expression rules and one for type rules. The multimap key is only a coarse bucket for collecting candidate rules; whether a candidate actually matches is decided by the matching engine. The bucket key is derived from the C++ signature:
- For expressions, the qualified function name with the return type, the
parameter list, and all template arguments stripped, so the rule for
_Bool std::vector<T1>::empty() constlands in thestd::vector::emptybucket. - For types, the text up to the first
<, so allstd::vector<...>rules land in thestd::vectorbucket.
During translation, the converter prints the construct it encounters with the
same canonical printer used by cpp-rule-preprocessor, which is what makes the
two sides comparable:
- Functions and methods print as
<return type> <qualified::name>(<param types>[, ...])[ const][ volatile][ &|&&]. - Enum constants and global variables print as their qualified name.
- Integer literals expanded from a macro print as the macro name.
All rules in the matching bucket are then unified against this string by
the matching engine, which binds T1…T9 to the concrete
types at the use site and picks the most specific rule when several match. Type
lookups first try the sugar-preserving spelling (so a rule can match size_t as
written) and retry with the desugared type on failure.
Running cpp2rust with --verbose logs every lookup and the rule it matched,
which is the quickest way to see why a rule does or does not fire.
Application
When a rule matches, the converter walks its body fragments and emits:
textfragments verbatim,placeholderfragments as the translated call-site argument. How the argument is emitted depends on the placeholder’s access and on whether the argument and the declared parameter type are pointers:- Read access emits the argument as a plain value, with an implicit numeric cast when the parameter type asks for one.
- Write access emits the argument as an lvalue.
- Move access wraps the argument in
std::mem::take(&mut ...); temporaries are moved as-is. - If the rule declares a pointer parameter but the argument is not a pointer,
the converter takes a fresh pointer to it,
materializing a temporary when the argument has
no address of its own. For example, the refcount
std::maxrule declaresPtr<T1>parameters, since the C++ side takesconst T1 &and the refcount model translates references asPtr, sostd::max(x1, x2)on plain locals substitutesx1.as_pointer()andx2.as_pointer()for the placeholders, whilestd::max(30, 40)first materializes__tmp_0and__tmp_1values for the literals and points into those. - If the receiver argument is a pointer but the rule expects a value, the converter dereferences it; this only happens for receivers, not for ordinary arguments.
genericfragments as the Rust mapping of the bound C++ type,va_argsfragments as the converted variadic tail,method_callfragments as receiver followed by body, possibly rewritten (see Rule Rewriting).
Multi-statement bodies are wrapped in { } so they remain a single expression.
Rules for user-defined C++ types are injected through the same mechanism at
translation time.
The Matching Engine
Loading and Matching collects the candidate rules for a
construct from a bucket; each candidate’s source signature is then unified
against the printed construct. The signature is treated as a template whose
T1…T9 slots capture concrete types: std::vector<T1>::vector() unifies
with std::vector<int>::vector() by binding T1 = int.
Unification works on the two strings:
- Whitespace differences are ignored.
- A
TNslot captures up to the next literal text of the pattern, found at the same<>/()/[]nesting depth. This is howT1captures all ofstd::map<int, int>instd::vector<std::map<int, int>>without stopping at the inner comma. - A
TNthat appears again must match its first capture exactly. - The whole printed string must be consumed; trailing text fails the match.
- Slots may stay unbound (a pattern can use
T2withoutT1).
A rule matches if unification succeeds. When several rules in the bucket match, the one with the longest source signature wins, so more specific rules take precedence; between equally long signatures the choice is unspecified.
Bucket keys
The bucket keys described in Loading and Matching have
two special cases: array types bucket by the text after the first [ rather
than the text before a <, and operator() rules are cut at the operator’s own
parentheses, so their key ends at ...::operator.
Instantiating the target
Captures are C++ spellings. Before being substituted into the rule’s Rust
fragments, each capture is itself mapped through the type rules, recursively, so
T1 = std::vector<int> substitutes as Vec<i32>. A captured type with no type
rule of its own is an error.
Rule Rewriting
A rule body is written against idiomatic Rust types: a rule that mutates a
vector declares its parameter as &mut Vec<T1>. But in the refcount model the
call-site argument is usually a Ptr<Vec<T1>>, and a Ptr
cannot produce a long-lived &mut. Instead of forcing
every rule to handle pointers, the code generator rewrites the rule body at
application time.
The with_mut rewrite
libcc2rs provides
#![allow(unused)]
fn main() {
impl<T> Ptr<T> {
pub fn with_mut<R>(&self, f: impl FnOnce(&mut T) -> R) -> R { ... }
}
}
which checks the pointer, borrows the pointee mutably, and runs the closure on
it (with an immutable sibling Ptr::with). The refcount converter uses it to
bridge the gap; the unsafe converter never rewrites and simply emits receiver
followed by body. The rewrite fires when all three hold:
- The rule body fragment is a method call whose receiver contains a placeholder (the preprocessor splits every method call into receiver and body fragments precisely to enable this). If the receiver contains several placeholders, the first one is used.
- The receiver placeholder’s access is write or move, i.e. the method takes
&mut selfor the rule mutates the parameter. Read access does not need the rewrite, since a read can go through aStrongPtrobtained withPtr::upgrade, or through aread()copy. - The call-site argument is a pointer, or an expression of reference type (which includes an operator call returning a reference).
The rule’s method call a0.method(...) is then emitted as
#![allow(unused)]
fn main() {
ptr.with_mut(|__v: <rule param type>| __v.method(...))
}
For example, the push_back rule is written as an ordinary &mut method call:
#![allow(unused)]
fn main() {
fn f21<T1: Clone>(a0: &mut Vec<T1>, a1: T1) { ... a0.push(...) }
}
Given the C++ input v.push_back(20); where v is reached through a
Ptr<Vec<i32>>, the generated code is:
#![allow(unused)]
fn main() {
v.with_mut(|__v: &mut Vec<i32>| __v.push(20));
}
When the receiver is a plain local value rather than a pointer, condition 3
fails and no closure is emitted; the same rule produces a direct call like
(*v2.borrow_mut()).push(0);.
The rewrite applies to pointer dereferences (p->push_back(20)) and to
reference usages (r.push_back(20) with std::vector<int> &r = *p); both are
translated as a Ptr, and that Ptr is what
with_mut is called on.
When the pointee is itself a boxed value (Value<T>, i.e. Rc<RefCell<T>>),
the closure takes &mut Value<T> and an extra borrow is inserted. This is the
case for nested containers: the refcount model translates
std::vector<std::vector<int>> as Vec<Value<Vec<i32>>> so that each element
has interior mutability of its own, and a Ptr to an inner vector therefore
points at a Value<Vec<i32>>, not a Vec<i32>:
#![allow(unused)]
fn main() {
ptr.with_mut(|__v: &mut Value<Vec<i32>>| (*__v.borrow_mut()).push(20))
}
The closure type is built from the C++ argument’s type, not the rule’s declared parameter type.
The read-access counterpart
For read access the converter does not emit a closure. A pointer receiver whose
rule parameter is a value or & type is simply dereferenced (p.read() or
(*p.upgrade().deref())); conversely, if the rule declares a Ptr parameter
but the argument is not a pointer, the converter inserts an as_pointer() cast
or materializes a temporary.
Preprocessor-side rewrites
Two rewrites in rule-preprocessor exist to make the with_mut rewrite
possible. Both apply only to &mut parameters:
- A
*deref in front of the parameter is dropped from the body, since the substituted argument is already an lvalue or pointer expression. std::mem::take(&mut aN)collapses to a bare placeholder, so the converter can re-express the move against the actual argument (for a pointer that becomesstd::mem::take(&mut <lvalue>)on the borrowed pointee). The spelling must be exactly this fully qualified form:mem::takeor an importedtakeis not rewritten. The collapsed placeholder’s access is leftunknownin phase 1; phase 2 resolves thestd::mem::takecall to a move.
Overview
This part of the book documents the internals of the code generator: how the clang AST is traversed and how Rust code is emitted.
Pointers and References
TODO: explain how the two models translate C++ pointers and references, in particular why the refcount model maps both to
Ptr<T>, and the role ofStrongPtrfor reading through a pointer.
Temporary Materialization
TODO: explain how the converter materializes temporaries for expressions that need an address but have none (e.g. literals passed where a pointer is expected).
Translation Plugins
TODO: document the converter plugin mechanism (
cpp2rust/converter/plugins/), which intercepts constructs ahead of the translation rules (currentlyemplace_back).