Rendering Foreign Bindings
This document details the general system that UniFFI uses to render the foreign bindings code.
The Askama template engine
Our foreign bindings generation uses the Askama template rendering engine. Askama uses a compile-time macro system that allows the template code to use Rust types directly, calling their methods passing them to normal Rust functions.
The task of the templates is to render the ComponentInterface
, which is the Rust representation of the UDL file, into
a bindings source file. This mainly consists of rendering source code for each Type
from the UDL.
Type matching
One of the main sources of complexity when generating the bindings is handling types. UniFFI supports a large number of
types, each of which corresponds to a variant of the Type enum
.
At one point there was a fairly large number of "mega-match" functions, each one matching against all Type
variants.
This made the code difficult to understand, because the functionality for one kind of type was split up.
Our current system for handling this is to have exactly 2 matches against Type
:
- One match lives in the template code. We map each Type
variant to a template file that renders definitions and
helper code, including:
- Class definitions for records, enums, and objects.
- Base classes and helper classes, for example
ObjectRuntime.kt
contains shared functionality for all the Type::Object
types.
- The FfiConverter class definition. This handles lifting and lowering
types across the FFI for the type.
- Initialization functions
- Importing dependencies
- See
Types.kt
for an example.
- The other match lives in the Rust code. We map each Type
variant to a implementation of the CodeType
trait that
renders identifiers and names related to the type, including:
- The name of the type in the foreign language
- The name of the FfiConverter
class
- The name of the initialization function
- See
KotlinCodeOracle::create_code_type()
for an example.
Why is the code organized like this? For a few reasons:
- Defining Askama templates in Rust required a lot of boilerplate. When the Rust code was responsible for
rendering the class definitions, helper classes, etc., it needed to define a lot of Askama
template structs which
lead to a lot of extra lines of code (see PR #1189)
- It's easier to access global state from the template code. Since the Rust code only handles names and
identifiers, it only needs access to the Type
instance itself, not the
ComponentInterface
or the
Config
. This simplifies the Rust side of things (see PR #1191).
Accessing the ComponentInterface
and Config
from the template code is easy, we simply define these as fields on
the top-level template Struct then they are accessible from all child templates.
- Putting logic in the template code makes it easier to implement external types. For
example, at one point the logic to lift/lower a type lived in the Rust code as a function that generated the
expression in the foreign language. However, it was not clear at all how to make this work for external types,
it would probably require parsing multiple UDL files and managing multiple ComponentInterfaces. Putting the logic
to lift/lower the type in the FfiConverter
class simplifies this, because we can import the external
FfiConverter
class and use that. We only need to know the name of the FfiConverter
class which is a simpler
task.
Askama extensions
A couple parts of this system require us to "extend" the functionality of Askama (i.e. adding hacks to workaround its limitations).
Adding imports
We want our type template files to specify what needs to be imported, but we don't want it to render the import statements directly. The imports should be rendered at the top of the file and de-duped in case multiple types require the same import. We handle this by:
- Defining a separate Askama template struct that loops over all types and renders the definition/helper code for them.
- That struct also stores a
BTreeSet
that contains the needed import statements and has anadd_import()
method that the template code calls. Using aBTreeSet
ensures the imports stay de-duped and sorted. - Rendering this template as a separate pass. The rendered string and the list of imports get passed to the main template which arranges for them to be placed in the correct location.
Including templates once
We want our type template files to render runtime code, but only once. For example, we only want to render
ObjectRuntime.kt
once, even if there are multiple Object types defined in the UDL file. To handle this the type
template defines an include_once_check()
method, which tests if we've included a file before. The template code then
uses that to guard the Askama {% include %}
statement. See Object.kt
for an
example