Bindings IR Pipeline
Note: the Bindings IR is currently an experiment. It's checked in so that we can use it for the gecko-js external binding generator. Our current recommendation for other external bindings authors is to avoid using it for now since we haven't fully committed to this new system and expect it to change.
The Bindings IR pipeline is used to transform different intermediate representations of the generated bindings.
- The foundational code lives in the
uniffi_pipelinecrate. This defines things like theNodeandMapNodetraits. - The macro code lives in the
uniffi_internal_macroscrate. This defines derive macros forNodeandMapNode. uniffi_bindgendefines the general pipeline. This convertsuniffi_metametadata to the initial IR then converts that to the general IR.- Finally, each language defines their own pipeline, which extends the general pipeline and outputs a language-specific IR.
Nodes
Types inside an IR are called "nodes" and they must derive a Node trait impl.
The top-level node is always named Root.
When this document talks about converting between two IRs, this means converting between the root nodes of those IRs.
Node trait provides functionality for walking the IR tree.
Node::visit()andNode::try_visit()allows you to visit all descendants with a given type.Node::has_descendant()tests if a predicate is true for any descendant.
These are useful when you want to populate a node field using it's descendants. For example, building up the FFI definitions by visiting all functions/methods inside a namespace.
Node methods input a closure that inputs a node type, for example root_node.visit(|func: Function| ...).
visit() will walk the node tree and call that closure with all matching nodes. The methods panic
if there are no descendant types with the input node type. This checks the types involved, not the
values. If there is a Vec<Function> field then visit() will not panic, even if the vec is empty.
MapNode
The MapNode trait is used to convert from one node to another and is defined like this:
pub trait MapNode<Output, Context> {
fn map_node(self, context: &Context) -> Result<Output>;
}
Selfis a node type from the previous IROutputis a node type from current IRContextis a generic context type which can be used to pass data to descendant nodes.
Here's an example of how a MapNode implementation might look:
impl MapNode<Namespace, Context> for prev_ir::Namespace {
fn map_node(self, context: &Context) -> Result<Namespace> {
// Create a new context for our descendants
let mut child_context = context.clone();
let context = &mut context;
context.set_namespace_name(&self.name);
// Map the previous `Namespace` node into the current version
Ok(Namespace {
// New fields are usually populated by calling a function
ffi_definitions: generate_ffi_definitions(&self, context)?,
// Existing fields are usually converted using recursive `map_node` calls.
name: self.name,
functions: self.functions.map_node(context)?,
type_definitions: self.type_definitions.map_node(context)?,
// ...
});
}
}
MapNode can be derived automatically. For the previous impl that would look like:
#[derive(Node, MapNode)]
// Use `#[map_node(from([type_name]))]` to declare the type we're mapping from
#[map_node(from(prev_ir::Namespace))]
// Use `#[map_node(update_context([expr]))]` update the context for descendant `map_node()` calls.
#[map_node(update_context(context.set_namespace_name(&self.name)))]
pub struct Namespace {
// Use `#[map_node(expr)]` to manually define the expression to populate a field.
#[map_node(generate_ffi_definitions(&self, context)?)]
ffi_definitions: Vec<FfiDefinition>,
// If no `#[map_node]` attribute is present, then fields will be mapped using recursive
// `map_node()` calls.
name: String,
functions: Vec<Function>,
type_definitions: Vec<TypeDefinition>,
// Note: The derived impl will convert fields in the order they're defined in. This means we
// need to put `ffi_definitions` first, so that we can take a reference to `self` before `self`
// is deconstructed for the `map_node` calls.
}
The MapNode derive macro supports a few other attributes:
#[derive(Node, MapNode)]
#[map_node(from(prev_ir::TypeNode))]
// When the type itself has the `#[map_node([path-to-function])]` attribute, then that function will
// be used for the entire mapping logic. In this case `MapNode::map_node` will forward the call
// `types::map_type_node`.
//
// Use this as an escape hatch for mappings that can't be auto-generated.
#[map_node(types::map_type_node)]
pub struct TypeNode {
//...
}
#[derive(Node, MapNode)]
#[map_node(from(uniffi_meta::Type))]
pub enum Type {
// Use `#[map_node(from)]` on variants/fields when they've been renamed from the previous IR
#[map_node(from(Object))]
Interface {
#[map_node(from(prev_name_field))]
name: String,
// ...
},
// Use `#[map_node(added)]` for variants that have been added in this IR. The generated code
// will not try to map these variants since they didn't exist in the previous IR
#[map_node(added)]
External {
// ...
},
}
A common pattern is wanting mapping an enum to a struct so that we can add fields that apply to all variants. This can be achieved like this:
#[derive(Node, MapNode)]
#[map_node(from(uniffi_meta::Type))]
pub struct TypeNode {
#[map_node(ffi_types::ffi_type(&self, context)?)]
ffi_type: FfiType,
#[map_node(self)]
ty: Type,
}
Finally, if a type hasn't changed at all between IRs, then it can be re-used using the
use_prev_node! macro. Note this only works if none of the fields have been changed either.
// Radix is a very simple Enum and doesn't change between IRs
use_prev_node!(uniffi_meta::Radix);
// Sometimes we want to map unchanged types using a function. For example, applying rename
// logic to certain `Type` variants. This can be achieved by specifying the map function as the
// second argument.
use_prev_node!(uniffi_meta::Type, types::map_type);
Context types
The Context type provides a way for nodes to pass data from one node down to the map_node()
methods of descendant nodes. Here are some examples of how it's used:
- Passing the crate name down so it can be used to derive FFI function names.
- Passing the namespace name down so it can be used to determine which types are external.
- Passing the current type down so it can be used to populate
CallableKind::Method.self_type.
Context can also be used to store data from outside the pass. For example, to implement the
renaming logic in the general pass, it needs to know the key in the TOML file that contains the
rename map (python, kotlin, swift etc). To handle this, the general::Context struct is
constructed with that key stored inside it.
Finally, Context types also act as marker types for the MapNode trait. For example, the above
code wants to call types::map_type when mapping Type for one pass, but it shouldn't be called
for the next pass. This works because the MapNode impl is specific to the Context type for the
pass.
Module structure
Each IR will typically have a module dedicated to them with the following structure:
mod.rs-- Top-level module.nodes.rs-- Node definitions withMapNodederives.context.rs-- Defines theContextstruct- other submodules -- Define functions to implement the IR pass
Assembling a pipeline
Pipelines are assembled by adding a series of passes that map one root node to another.
For example:
// Pipeline defined `uniffi_bindgen::pipeline::general`
//
// This is the shared start for all bindings pipelines.
// It maps `uniffi_bindgen::pipeline::initial::Root` to
// `uniffi_bindgen::pipeline::general::Root`
//
// Note that the context is constructed with `bindings_toml_key`. This is how you can pass data
// from outside the pass into the `map_node` methods.
pub fn pipeline(bindings_toml_key: &str) -> Pipeline<initial::Root, Root> {
new_pipeline()
.pass::<Root, Context>(Context::new(bindings_toml_key))
}
// Pipeline defined `uniffi_bindgen::python::pipeline`
//
// This extends the general pipeline to map to
// `uniffi_bindgen::python::pipeline::Root`
pub fn pipeline() -> Pipeline<initial::Root, Root> {
general::pipeline()
.pass::<Root, Context>(Context::default())
}
Starting a IR for binding generation
The first step is creating a skeleton for your IR:
- Define
pipelinemodule in your crate. - Define a
Contexttype inpipeline/context.rs. To start with, this can be an empty struct that derivesDefault. - Define the IR nodes in
pipeline/node.rs. To start, this can just beuse_prev_node!macros for each node from the general IR. - Setup imports (optional).
pipeline/mod.rsfile will normally importnodes::*,anyhow::{anyhow, bail, Result}and other items that are commonly used in the IR.- The other mods will normally import
super::*. - This step is definitely not necessary, it's just how pipeline modules are typically setup.
From there you can evolve the code to match your needs. For example:
- Adding a new field
- Copy and paste the type definition from the general IR into your
nodes.rsfile. - Remove any
#[map_node]attributes. - Add
#[map_node(from(general::[NodeType]))to the type itself. - Add the new field with a
#[map_node([expression_to_generate_field])attribute. - Find any types that reference the changed type and repeat steps 1-3.
- Copy and paste the type definition from the general IR into your
- Mapping an enum to a struct
- Define a new struct type.
If your enum is named
Foo, this will typically be namedFooNode. - Add a field that stores the original enum. Wrap this with a
#[map_node(self)]attribute. - Add a new field, with a
#[map_node(expr)]attribute - Find any types that reference the changed type and repeat steps 1-3 from adding a new field.
- Define a new struct type.
If your enum is named
Peeking behind the curtains with the pipeline CLI
Use the pipeline subcommand from any UniFFI CLI to inspect IR data at various stages of the pipeline.
- Build a UniFFI crate that you'd like to inspect, for example
cargo build -p uniffi-example-arithmetic - Run the uniffi-bindgen CLI, with these arguments
pipeline --library path/to/library.so_a_or_dll [language] - For example, in the UniFFI repo,
cargo run -p uniffi-bindgen-cli -- pipeline --library target/debug/libarithmetical.so python
This will print out:
- The initial IR
- The diff after each pass
- The final IR
This is a lot of data. Use CLI flags to reduce it to a reasonable amount:
-p,--passonly show a single pass.- Use
-p finalto only show the final pass in the process, which can be usefull when you're adding pass functions and want to see their effects. -t,--typeto only show one node type (e.g.-t Recordor-t Interface).-n,--nameto only show with nodes with a specific name
Alternatively, if you want to see the full IR for each pass, you can use --no-diff to print it out.
Piping to a pager like less is highly recommended in this case.
You can test this out yourself by running the following command to follow the add function as it moves through the IR pipeline:
cargo run -p uniffi-bindgen-cli -- pipeline --library target/debug/libarithmetical.so python -t Function -n add | less