Tutorial

A Zngur project consists of 3 things:

  • An IDL (interface definition language) file named main.zng
  • A Rust crate (that can be everything, binary, rlib, static-lib, cdy-lib, ...)
  • A C++ project.

To start, install Zngur:

cargo install zngur-cli

Then generate a new staticlib crate using cargo init and appending this to the Cargo.toml:

[lib]
crate-type = ["staticlib"]

And create an empty main.cpp and main.zng file. Your directory tree should look like this:

├── Cargo.toml
├── main.cpp
├── main.zng
└── src
    └── lib.rs

Basic structure of main.zng

Imagine we want to use this inventory in C++:

struct Item {
    name: String,
    size: u32,
}

struct Inventory {
    items: Vec<Item>,
    remaining_space: u32,
}

impl Inventory {
    fn new_empty(space: u32) -> Self {
        Self {
            items: vec![],
            remaining_space: space,
        }
    }
}

Copy it into src/lib.rs. Now we need to declare things that we need to access in C++ in the main.zng file:

type crate::Inventory {
    #layout(size = 32, align = 8);

    fn new_empty(u32) -> crate::Inventory;
}

Zngur needs to know the size and align of the types inside the bridge. You can figure it out using the rust-analyzer (by hovering over the struct or type alias) or fill it with some random number and then fix it from the compiler error.

Note: Ideally main.zng file should be auto-generated, but we are not there yet. Also, Zngur can work without explicit size and align (with some caveats), see layout policies for more details.

Now, run zngur g ./main.zng to generate the C++ and Rust glue files. It will generate a ./generated.h C++ header file, and a ./src/generated.rs file. Add a mod generated; to your lib.rs file to include the generated Rust file. Then fill main.cpp file with the following content:

#include "./generated.h"

int main() {
  auto inventory = rust::crate::Inventory::new_empty(1000);
}

Zngur will add every Rust item with its full path in the rust namespace, so for example String will become rust::std::string::String in the C++ side.

To build it, you need to first build the Rust code using cargo build, which will generate a libyourcrate.a in the ./target/debug folder, and you can build your C++ code by linking to it:

clang++ main.cpp -g -L ./target/debug/ -l your_crate

To ensure that everything works, let's add a #[derive(Debug)] to Inventory and use zngur_dbg to see it:

type crate::Inventory {
    #layout(size = 32, align = 8);
    wellknown_traits(Debug);

    fn new_empty(u32) -> crate::Inventory;
}
int main() {
  auto inventory = rust::crate::Inventory::new_empty(1000);
  zngur_dbg(inventory);
}

There are some traits that Zngur has special support for them, and Debug is among them. This page has the complete list of them.

Assuming that everything works correctly, you should see something like this after executing the program:

[main.cpp:5] inventory = Inventory {
    items: [],
    remaining_space: 1000,
}

Now let's add some more methods to it:

impl Inventory {
    fn add_item(&mut self, item: Item) {
        self.remaining_space -= item.size;
        self.items.push(item);
    }

    fn add_banana(&mut self, count: u32) {
        for _ in 0..count {
            self.add_item(Item {
                name: "banana".to_owned(),
                size: 7,
            });
        }
    }
}
type () {
    #layout(size = 0, align = 1);
}

type crate::Inventory {
    #layout(size = 32, align = 8);
    wellknown_traits(Debug);

    fn new_empty(u32) -> crate::Inventory;
    fn add_banana(&mut self, u32);
}

Note that the return type of add_banana is the () type, so we need to add it as well. Now we can use it in the C++ file:

#include "./generated.h"

int main() {
  auto inventory = rust::crate::Inventory::new_empty(1000);
  inventory.add_banana(3);
  zngur_dbg(inventory);
}
[main.cpp:6] inventory = Inventory {
    items: [
        Item {
            name: "banana",
            size: 7,
        },
        Item {
            name: "banana",
            size: 7,
        },
        Item {
            name: "banana",
            size: 7,
        },
    ],
    remaining_space: 979,
}

Bridging the add_item method requires a little more effort. We need to declare crate::Item type as well since it is an argument of that function:

// ...

type crate::Item {
    #layout(size = 32, align = 8);
}

type crate::Inventory {
    // ...
    fn add_item(&mut self, crate::Item);
}

But using that alone we can't use the add_item since there is no way to obtain a rust::crate::Item in the C++ side. To fix that, we need to add the constructor for the Item:

type ::std::string::String {
    #layout(size = 24, align = 8);
}

type crate::Item {
    #layout(size = 32, align = 8);

    constructor { name: ::std::string::String, size: u32 };
}

But it doesn't solve the problem, since we can't create a String so we can't call the constructor. To creating a String, we declare the primitive type str and its to_owned method:

type str {
    wellknown_traits(?Sized);

    fn to_owned(&self) -> ::std::string::String;
}

There are some new things here. First, since str is a primitive it doesn't need full path. Then there is wellknown_traits(?Sized) instead of #layout(size = X, align = Y) which tells Zngur that this type is unsized and it should consider its references as fat and prevent storing it by value.

Now you may wonder how we can obtain a &str to make a String from it? Fortunately, Zngur has some special support for primitive types and it has a rust::Str::from_char_star function that creates a &str from a zero terminated, valid UTF8 char* with the same lifetime. If Zngur didn't have this, we could create a &[u8] by exporting its from_raw_parts and then converting it to a &str, from_char_star exists just for convenience.

So now we can finally use the add_item method:

int main() {
  auto inventory = rust::crate::Inventory::new_empty(1000);
  inventory.add_banana(3);
  rust::Ref<rust::Str> name = rust::Str::from_char_star("apple");
  inventory.add_item(rust::crate::Item(name.to_owned(), 5));
  zngur_dbg(inventory);
}
[main.cpp:8] inventory = Inventory {
    items: [
        Item {
            name: "banana",
            size: 7,
        },
        Item {
            name: "banana",
            size: 7,
        },
        Item {
            name: "banana",
            size: 7,
        },
        Item {
            name: "apple",
            size: 5,
        },
    ],
    remaining_space: 974,
}

Generic types

Let's try to add and bridge the into_items method:

impl Inventory {
    fn into_items(self) -> Vec<Item> {
        self.items
    }
}

Vec is a generic type, but the syntax to use it is not different:

type ::std::vec::Vec<crate::Item> {
    #layout(size = 24, align = 8);
    wellknown_traits(Debug);
}

type crate::Inventory {
    // ...
    fn into_items(self) -> ::std::vec::Vec<crate::Item>;
}

Note that this only brings Vec<Item>, for using Vec<i32> or Vec<String> or Vec<SomethingElse> you need to add each of them separately.

Now you can use into_items method in C++:

rust::std::vec::Vec<rust::crate::Item> v = inventory.into_items();
zngur_dbg(v);
[main.cpp:11] v = [
    Item {
        name: "banana",
        size: 7,
    },
    Item {
        name: "banana",
        size: 7,
    },
    Item {
        name: "banana",
        size: 7,
    },
    Item {
        name: "apple",
        size: 5,
    },
]

You can see the full code at examples/tutorial

Calling C++ from Rust

C++/Rust interop has two sides, and no interop tool is complete without supporting both. Here, we will do the reverse of the above task, swapping the Rust and C++ rules. So, let's assume we have this C++ code:

#include <string>
#include <vector>

namespace cpp_inventory {
struct Item {
  std::string name;
  uint32_t size;
};

struct Inventory {
  std::vector<Item> items;
  uint32_t remaining_space;

  Inventory(uint32_t space) : items(), remaining_space(space) {}

  void add_item(Item item) {
    remaining_space -= item.size;
    items.push_back(std::move(item));
  }

  void add_banana(uint32_t count) {
    add_item(Item{
        .name = "banana",
        .size = 7,
    });
  }
};

} // namespace cpp_inventory

Create a new cargo project, this time a binary one since we want to write the main function to live inside Rust. Copy the above code into the inventory.h file. Then create a main.zng file with the following content:

type crate::Inventory {
    #layout(size = 16, align = 8);

    constructor(ZngurCppOpaqueOwnedObject);

    #cpp_value "0" "::cpp_inventory::Inventory";
}

type crate::Item {
    #layout(size = 16, align = 8);

    constructor(ZngurCppOpaqueOwnedObject);

    #cpp_value "0" "::cpp_inventory::Item";
}

And add these to the main.rs file:

mod generated {
    include!(concat!(env!("OUT_DIR"), "/generated.rs"));
}

struct Inventory(generated::ZngurCppOpaqueOwnedObject);
struct Item(generated::ZngurCppOpaqueOwnedObject);

This time we will use the Zngur generator inside of cargo build script. We could still use the zngur-cli but in projects where cargo is the boss, using build script is better. Add zngur and cc to your build dependencies:

[build-dependencies]
cc = "1.0"
build-rs = "0.1.2" # This one is optional
zngur = "latest-version"

Then fill the build.rs file:

use std::{env, path::PathBuf};

use zngur::Zngur;

fn main() {
    build::rerun_if_changed("main.zng");
    build::rerun_if_changed("impls.cpp");

    let crate_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").unwrap());
    let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());

    Zngur::from_zng_file(crate_dir.join("main.zng"))
        .with_cpp_file(out_dir.join("generated.cpp"))
        .with_h_file(out_dir.join("generated.h"))
        .with_rs_file(out_dir.join("generated.rs"))
        .generate();

    let my_build = &mut cc::Build::new();
    let my_build = my_build
        .cpp(true)
        .compiler("g++")
        .include(&crate_dir)
        .include(&out_dir);
    let my_build = || my_build.clone();

    my_build()
        .file(out_dir.join("generated.cpp"))
        .compile("zngur_generated");
    my_build().file("impls.cpp").compile("impls");
}

Now we have a crate::Inventory and a crate::Item that can contain their C++ counterparts. But there is no way to use them in Rust. In Zngur, the Rust side can't access C++ opaque objects. So to make these types useful in Rust, we can add impl blocks for these types in C++. Add this to the main.zng:

type str {
    wellknown_traits(?Sized);

    fn as_ptr(&self) -> *const u8;
    fn len(&self) -> usize;
}

extern "C++" {
    impl crate::Inventory {
        fn new_empty(u32) -> crate::Inventory;
        fn add_banana(&mut self, u32);
        fn add_item(&mut self, crate::Item);
    }

    impl crate::Item {
        fn new(&str, u32) -> crate::Item;
    }
}

Now we can define these methods in the C++ and use them in Rust. Create a file named impls.cpp with this content:

#include "generated.h"
#include <string>

using namespace rust::crate;

Inventory rust::Impl<Inventory>::new_empty(uint32_t space) {
  return Inventory(
      rust::ZngurCppOpaqueOwnedObject::build<cpp_inventory::Inventory>(space));
}

rust::Unit rust::Impl<Inventory>::add_banana(rust::RefMut<Inventory> self,
                                             uint32_t count) {
  self.cpp().add_banana(count);
  return {};
}

rust::Unit rust::Impl<Inventory>::add_item(rust::RefMut<Inventory> self,
                                           Item item) {
  self.cpp().add_item(item.cpp());
  return {};
}

Item rust::Impl<Item>::new_(rust::Ref<rust::Str> name, uint32_t size) {
  return Item(rust::ZngurCppOpaqueOwnedObject::build<cpp_inventory::Item>(
      cpp_inventory::Item{
          .name = ::std::string(reinterpret_cast<const char *>(name.as_ptr()),
                                name.len()),
          .size = size}));
}

These functions look like some unnecessary boilerplate, but writing them has some benefits:

  • We can convert C++ types to the Rust equivalents in these functions. For example, converting a pointer and length to a slice, or &str to std::string that happened in the Item::new above.
  • We can convert exceptions to Rust Result or Option.
  • We can control the signature of methods, and use proper lifetimes and mutability for references. In case of mutability, Rust mutability means exclusiveness, which might be too restrictive and we may want to consider the C++ type interior mutable. We can also add nullability with Option or make the function unsafe.
  • We can choose Rusty names for the functions (like new and len) or put the functionality in the proper trait (for example implementing the Iterator trait instead of exposing the .begin and .end functions)

Even in the tools that support calling C++ functions directly, people often end up writing Rust wrappers around C++ types for these reasons. In Zngur, that code is the wrapper, which lives in the C++ so it can do whatever C++ does.

In the Rust to C++ side, we used zngur_dbg macro to see the result. We will do the same here with the dbg! macro. To do that, we need to implement the Debug trait for crate::Inventory. Add this to the main.zng:

// ...

type ::std::fmt::Result {
    #layout(size = 1, align = 1);

    constructor Ok(());
}

type ::std::fmt::Formatter {
    #layout(size = 64, align = 8);

    fn write_str(&mut self, &str) -> ::std::fmt::Result;
}

extern "C++" {
    // ...

    impl std::fmt::Debug for crate::Inventory {
        fn fmt(&self, &mut ::std::fmt::Formatter) -> ::std::fmt::Result;
    }
}

and this code to the impls.cpp:

rust::std::fmt::Result rust::Impl<Inventory, rust::std::fmt::Debug>::fmt(
    rust::Ref<::rust::crate::Inventory> self,
    rust::RefMut<::rust::std::fmt::Formatter> f) {
  ::std::string result = "Inventory { remaining_space: ";
  result += ::std::to_string(self.cpp().remaining_space);
  result += ", items: [";
  bool is_first = true;
  for (const auto &item : self.cpp().items) {
    if (!is_first) {
      result += ", ";
    } else {
      is_first = false;
    }
    result += "Item { name: \"";
    result += item.name;
    result += "\", size: ";
    result += ::std::to_string(item.size);
    result += " }";
  }
  result += "] }";
  return f.write_str(rust::Str::from_char_star(result.c_str()));
}

So now we can write the main function:

fn main() {
    let mut inventory = Inventory::new_empty(1000);
    inventory.add_banana(3);
    inventory.add_item(Item::new("apple", 5));
    dbg!(inventory);
}

and run it:

[examples/tutorial_cpp/src/main.rs:12] inventory = Inventory { remaining_space: 974, items: [Item { name: "banana", size: 7 }, Item { name: "banana", size: 7 }, Item { name: "banana", size: 7 }, Item { name: "apple", size: 5 }] }

You can see the full code at examples/tutorial_cpp