Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
ee9d69b
chore(quest): claim quest/m1/ffi-export.md
kixelated Oct 26, 2026
7fbc5de
chore(quest): record the several-crates decision for the exporter
kixelated Oct 26, 2026
3be0e15
Merge origin/main
kixelated Oct 26, 2026
c3fbbac
feat(ffi)!: export several crates into one library
kixelated Oct 26, 2026
9049f84
Merge origin/main
kixelated Oct 26, 2026
74dc851
fix(ffi): build each export as the check did
kixelated Oct 26, 2026
70a4b68
Merge remote-tracking branch 'origin/main' into quest/m1/ffi-export
kixelated Oct 26, 2026
21503fb
fix(ffi): refuse each export's re-exports on their own
kixelated Oct 26, 2026
9fed38d
Merge remote-tracking branch 'origin/main' into quest/m1/ffi-export
kixelated Oct 26, 2026
1c2fbdc
Merge remote-tracking branch 'origin/main' into quest/m1/ffi-export
kixelated Oct 26, 2026
84785d1
fix(ffi): find exports under the check's lockfile and network flags
kixelated Oct 26, 2026
38e1e1c
fix(ffi): find exports on the host, through symlinks
kixelated Oct 26, 2026
0dc0a5b
fix(ffi): check for the host the exports are found on
kixelated Oct 26, 2026
49e88a1
Merge remote-tracking branch 'origin/main' into quest/m1/ffi-export
kixelated Oct 26, 2026
07e7d54
fix(ffi): find exports in the library's own dependency graph
kixelated Oct 26, 2026
14a79e4
fix(ffi): read the checked package's tree, uncolored
kixelated Oct 26, 2026
a76c34f
Merge remote-tracking branch 'origin/main' into quest/m1/ffi-export
kixelated Oct 26, 2026
5c17432
fix(ffi): bind each export to its package's crate, not its crate name
kixelated Oct 26, 2026
3fcec97
fix(ffi): bind each export through the configured package's dependenc…
kixelated Oct 26, 2026
058ee46
Merge remote-tracking branch 'origin/main' into quest/m1/ffi-export
kixelated Oct 26, 2026
1d71d86
fix(ffi): name exports by resolved dependency, and apart from the glu…
kixelated Oct 26, 2026
2dd1961
fix(ffi): root each export's paths at its package name
kixelated Oct 26, 2026
1eb73fd
Merge remote-tracking branch 'origin/main' into quest/m1/ffi-export
kixelated Oct 26, 2026
a30c93d
fix(ffi): patch the glue's workspace as the configured one is
kixelated Oct 26, 2026
3da8851
Merge remote-tracking branch 'origin/main' into quest/m1/ffi-export
kixelated Oct 26, 2026
bf6ea3b
fix(ffi): absolutize only dependency paths in carried patches
kixelated Oct 26, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/nightly.yml
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ jobs:
run: nix develop --command just ffi census

- name: Summarize
run: awk '/^## Refused items/ { exit } { print }' ffi/target/census/moq-net.md >> "$GITHUB_STEP_SUMMARY"
run: awk 'FNR == 1 { refused = 0 } /^## Refused items/ { refused = 1 } !refused' ffi/target/census/*.md >> "$GITHUB_STEP_SUMMARY"

- name: Upload
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
Expand Down
4 changes: 2 additions & 2 deletions driver/src/cargo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};

/// The command line every driver takes:
/// `--config <file> (--out <dir> | --census <file>) [--manifest-path <Cargo.toml>] [-- <cargo args>]`.
/// `--config <file> (--out <dir> | --census <path>) [--manifest-path <Cargo.toml>] [-- <cargo args>]`.
pub struct Args {
/// The driver's config file, absolute.
pub config: PathBuf,
Expand All @@ -21,7 +21,7 @@ pub struct Args {
pub enum Output {
/// Its output, into this directory.
Dir(PathBuf),
/// A census of what it refuses, to this file, instead.
/// A census of what it refuses, to this file or directory, as the driver says, instead.
Census(PathBuf),
}

Expand Down
9 changes: 9 additions & 0 deletions ffi/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions ffi/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ allow_attributes_without_reason = "warn"
[dependencies]
rust2-driver = { path = "../driver" }
serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.154"
toml = "1.1.8"

[lints]
Expand Down
54 changes: 34 additions & 20 deletions ffi/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,20 @@ attributes in the Rust source. It replaces [the UniFFI fork](../uniffi/README.md
starting from C, which every other language's generator will build on.

```bash
rust2-ffi --config ffi.toml (--out dir | --census file.md) [--manifest-path Cargo.toml] [-- cargo args]
rust2-ffi --config ffi.toml (--out dir | --census dir) [--manifest-path Cargo.toml] [-- cargo args]
```

It runs `cargo check` on the package with itself as `RUSTC_WRAPPER`, on the host, through
the front end it shares with [rust2-ts](../ts/README.md) in [`driver/`](../driver). Once
rustc has checked the configured library, it walks every `pub` item reachable from the
crate root and exports it by fixed rules, or refuses it with a compile error at its span.
`--out` writes a crate of `extern "C"` functions over the library, its runtime beside it,
and the header in `include/<prefix>.h`; `cargo build` on that crate makes a static and a
shared library.
It walks each other package the config lists the same way, from the metadata of the
crate the library uses through its dependency on that package, so one library holds them
all and an export names another's types across crates. `--out` writes a crate of `extern "C"` functions over the
libraries, its runtime beside it, and the header in `include/<prefix>.h`; `cargo build`
on that crate makes a static and a shared library. That crate enables each export's
features as the check did, including `--features <export>/<feature>`; such a feature on a
dependency that is no export is an error.

```toml
# ffi.toml, beside the package's Cargo.toml unless --manifest-path says otherwise.
Expand All @@ -28,13 +32,19 @@ exclude = ["track::Producer::poll_closed"]
# C names, without the prefix, for paths whose names the naming rule makes clash.
[rename]
"session::Stats" = "session_stats_snapshot"

# Another package exported into the same library, which the package builds with: its
# own `exclude` and `rename`, by paths from its crate root.
[crates.moq-pattern]
exclude = ["Patterns::iter"]
```

## Rules

Every `pub` item reachable from the crate root, by its shortest path, is exported or
refused. `#[doc(hidden)]` items are no part of the API and are left out; type aliases
export nothing of their own.
Every `pub` item reachable from an exported crate's root, by its shortest path, is
exported or refused. `#[doc(hidden)]` items are no part of the API and are left out; type
aliases export nothing of their own. A re-export of another export's item names that
export's, and adds nothing; another crate's type is refused.

| Rust | C |
| --- | --- |
Expand Down Expand Up @@ -89,40 +99,44 @@ refused, and the census counts why.
implementation, and may call the others from any thread. A callback takes scalars and
buffers lent for the call, and returns a scalar.
- **Panics** abort, as an `extern "C"` function's do.
- **Names** are the prefix, then the item's path from the crate root, each segment in
- **Names** are the prefix, then the item's path from its crate's root, each segment in
snake case, joined by `_`: `track::Producer::append_group` is
`moq_track_producer_append_group`. Joining loses where segments split, so every C name
is registered, and two items claiming one is a compile error at both spans, fixed by a
`rename`: moq-net's `session::Stats` and `Session::stats` are both `moq_session_stats`.
`moq_track_producer_append_group`, and moq-pattern's `Patterns` is `moq_patterns`.
Joining loses where segments split, and every export shares the prefix, so every C
name is registered, and two items claiming one is a compile error at both spans, fixed
by a `rename`: moq-net's `session::Stats` and `Session::stats` are both
`moq_session_stats`.

## IR

[`src/ir.rs`](src/ir.rs) is the API as each generator reads it, with no language decided:
records, enums, objects, callbacks, functions, and constants, each by its path from the
crate root, its span, and its docs. A type is in the exact form Rust takes or returns it
records, enums, objects, callbacks, functions, and constants of every export, each by
its Rust path, its span, and its docs. A type is in the exact form Rust takes or returns it
(`&str` apart from `String`, `Box<dyn Trait>` apart from `impl Trait`), so a generator can
borrow where Rust borrows. [`src/abi.rs`](src/abi.rs) decides the C ABI from it once,
names and signatures, so [`src/c.rs`](src/c.rs) and [`src/glue.rs`](src/glue.rs) agree;
the runtime the glue includes is [`runtime/rt.rs`](runtime/rt.rs).

## Census

`--census file.md` exports nothing. It counts what exports and ranks every refusal by
construct and by the type it names, lists each refused item at its span, and ends with
the `exclude` list that exports the rest. It ignores the config's `exclude`.
`--census dir` exports nothing. It writes `<package>.md` for each exported package,
which counts what exports and ranks every refusal by construct and by the type it names,
lists each refused item at its span, and ends with the `exclude` list that exports the
rest. It ignores the config's `exclude`.

## Tests

```bash
just ffi test # export the fixtures, build them, and drive api from C
just ffi moq # export moq-net at the pinned revision, build it, and run moq/moq.c
just ffi census # census moq-net into target/census
just ffi moq # export moq-net and its exports at the pinned revision, build them, and run moq/moq.c
just ffi census # census each into target/census
nix develop .#bench --command just ffi bench # frame writes, through the export and moq's rs/moq-c
```

The `api` fixture covers each rule once. `tests/api.c` runs the same script as the
The `api` fixture covers each rule once, and names the `dep` fixture's types, which
export into the same library. `tests/api.c` runs the same script as the
fixture's `examples/transcript.rs`, and the two transcripts must match line for line;
then it breaks each runtime check, which must abort with its message. The `reject`
fixture's expected errors, each with its notes, are `fixtures/reject/expected.txt`.
moq-net's `moq/moq.c` publishes and subscribes to a track in one process, polling each
read, and makes an origin.
read, and scopes an origin to moq-pattern's patterns.
1 change: 1 addition & 0 deletions ffi/fixtures/api/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ name = "api_lib"

[dependencies]
bytes = "1.11.1"
dep-fixture = { path = "../dep" }

[lints]
workspace = true
7 changes: 6 additions & 1 deletion ffi/fixtures/api/examples/transcript.rs
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ use std::sync::atomic::{AtomicUsize, Ordering};
use std::task::{Context, Poll, Wake, Waker};

use api_lib::{
Channel, Counter, Event, LIMIT, Log, Logger, Message, Name, Node, Point, Registry, Shape, Stamp, Tally, Window,
Channel, Counter, Event, LIMIT, Log, Logger, Message, Name, Node, Point, Registry, Shape, Size, Stamp, Tally,
Window,
};
use bytes::Bytes;

Expand Down Expand Up @@ -193,6 +194,10 @@ fn main() {
println!("tally {} {}", tally.bump(), tally == tally);
println!("both {}", api_lib::bump_both(&tally, &tally));

let area = api_lib::grow(&dep::Area::new(Size { width: 2, height: 3 }), 1);
let size = area.size();
println!("area {} {} {}", size.width, size.height, area.pixels());

let token = api_lib::Token::new(7);
println!("token {} dropped {}", token.id, api_lib::dropped());
drop(token);
Expand Down
4 changes: 4 additions & 0 deletions ffi/fixtures/api/ffi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,7 @@ package = "api-fixture"
prefix = "api"
# The list's tail is not `Clone`, so its getter has no copy to return.
exclude = ["Node::next"]

# Exported into the same library: `grow` names its types.
[crates.dep-fixture]
exclude = ["HashSet"]
11 changes: 11 additions & 0 deletions ffi/fixtures/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ use std::task::{Poll, Waker};
use std::time::Duration;

use bytes::Bytes;
// Another export's type, which this names rather than exports again.
pub use dep::Size;

/// The largest value a [`Counter`] reaches.
pub const LIMIT: u64 = 1000;
Expand Down Expand Up @@ -481,3 +483,12 @@ pub fn greet(name: impl Into<String>, shape: Shape) -> String {
pub fn describe(event: &Event) -> String {
format!("{event:?}")
}

/// An area grown by `by` on each side: another export's types, across crates.
pub fn grow(area: &dep::Area, by: u32) -> dep::Area {
let size = area.size();
dep::Area::new(Size {
width: size.width + by,
height: size.height + by,
})
}
20 changes: 20 additions & 0 deletions ffi/fixtures/dep/Cargo.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
[package]
name = "dep-fixture"
description = "Another export, whose types the api fixture names across crates"
version = "0.0.0"
edition = "2024"
license = "MIT OR Apache-2.0"
publish = false

# Explicitly an rlib, which cargo metadata calls one.
[lib]
name = "dep"
crate-type = ["rlib"]

[features]
# Doubles each area's size: the tests enable it on this crate directly, which the export
# must build as the check did.
doubled = []

[lints]
workspace = true
39 changes: 39 additions & 0 deletions ffi/fixtures/dep/src/lib.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
//! Another export, exported into the api fixture's library, which names its types.

/// A size: a record of this export, which another's signatures name.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Size {
pub width: u32,
pub height: u32,
}

/// An area: an object of this export, which another's signatures take and return.
#[derive(Clone, Debug)]
pub struct Area {
size: Size,
}

impl Area {
/// An area of `size`, doubled on each side with the `doubled` feature.
pub fn new(size: Size) -> Self {
let scale = if cfg!(feature = "doubled") { 2 } else { 1 };
let size = Size {
width: size.width * scale,
height: size.height * scale,
};
Self { size }
}

pub fn size(&self) -> Size {
self.size
}

/// Its width times its height.
pub fn pixels(&self) -> u64 {
u64::from(self.size.width) * u64::from(self.size.height)
}
}

/// Another crate's item that is no export, which each export re-exporting it refuses or
/// excludes on its own.
pub use std::collections::HashSet;
3 changes: 3 additions & 0 deletions ffi/fixtures/reject/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,8 @@ edition = "2024"
license = "MIT OR Apache-2.0"
publish = false

[dependencies]
dep-fixture = { path = "../dep" }

[lints]
workspace = true
10 changes: 8 additions & 2 deletions ffi/fixtures/reject/expected.txt
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
fixtures/dep/src/lib.rs:39:9: rust2-ffi: cannot export use `HashSet`: re-export of another crate's item `std`; exclude it in the config's [crates.dep-fixture] to leave it out
fixtures/dep/src/lib.rs:5:1: rust2-ffi: struct `dep_fixture::Size` and struct `reject_fixture::Size` are both `reject_size_free` in C
fixtures/reject/src/lib.rs:139:1: note: struct `reject_fixture::Size` is here
fixtures/dep/src/lib.rs:5:1: rust2-ffi: struct `dep_fixture::Size` and struct `reject_fixture::Size` are both `reject_size` in C
fixtures/reject/src/lib.rs:139:1: note: struct `reject_fixture::Size` is here
fixtures/reject/src/lib.rs:113:2: rust2-ffi: cannot export method `Text::replace`: 'static borrow `&'b str`; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:113:17: note: `'b` is `'static`, as a handle outlives every call
fixtures/reject/src/lib.rs:118:2: rust2-ffi: cannot export method `Text::later`: returned object that borrows `impl std::future::Future<Output = Text<'_>>`; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:125:1: rust2-ffi: cannot export fn `either`: reference inside a parameter `std::option::Option<&str>`; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:130:1: rust2-ffi: cannot export struct `Lease`: object no export returns; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:149:9: rust2-ffi: cannot export use `HashSet`: re-export of another crate's item `std`; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:14:1: rust2-ffi: cannot export fn `map`: foreign type `std::collections::HashMap`; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:19:1: rust2-ffi: cannot export fn `generic`: generic parameter `std::marker::Copy`; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:26:1: rust2-ffi: cannot export fn `borrowing`: borrowing future from a function that is not async; exclude it in the config to leave it out
Expand All @@ -11,8 +17,8 @@ fixtures/reject/src/lib.rs:40:1: rust2-ffi: cannot export fn `peek`: reference t
fixtures/reject/src/lib.rs:45:1: rust2-ffi: cannot export static `COUNT`: static; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:49:1: rust2-ffi: cannot export macro `noop`: macro; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:57:1: rust2-ffi: cannot export fn `excluded`: excluded type `Excluded`; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:68:2: rust2-ffi: fn `audio::encoder_finish` and fn `audio_encoder::finish` are both `reject_audio_encoder_finish` in C
fixtures/reject/src/lib.rs:63:2: note: fn `audio_encoder::finish` is here
fixtures/reject/src/lib.rs:68:2: rust2-ffi: fn `reject_fixture::audio::encoder_finish` and fn `reject_fixture::audio_encoder::finish` are both `reject_audio_encoder_finish` in C
fixtures/reject/src/lib.rs:63:2: note: fn `reject_fixture::audio_encoder::finish` is here
fixtures/reject/src/lib.rs:6:1: rust2-ffi: cannot export struct `Wrap`: generic type; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:72:1: rust2-ffi: cannot export fn `letters`: Range `char`; exclude it in the config to leave it out
fixtures/reject/src/lib.rs:81:2: rust2-ffi: cannot export method `Text::new`: 'static borrow `&'a str`; exclude it in the config to leave it out
Expand Down
3 changes: 3 additions & 0 deletions ffi/fixtures/reject/ffi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@ package = "reject-fixture"
prefix = "reject"
# An exclusion leaves an item out, so nothing refuses it.
exclude = ["Excluded"]

# Another export, whose `Size` claims the same C name as this one's.
[crates.dep-fixture]
13 changes: 13 additions & 0 deletions ffi/fixtures/reject/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,16 @@ impl Lease {
Lease(self.0)
}
}

/// `reject_size` in C, as another export's `Size` is: one prefix serves every export.
pub struct Size {
pub bytes: u64,
}

/// Another export's type, which this one names.
pub fn measure(size: dep::Size) -> dep::Area {
dep::Area::new(size)
}

/// Another crate's item that is no export, so nothing names it.
pub use std::collections::HashSet;
4 changes: 2 additions & 2 deletions ffi/justfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ fix:
run *args:
cargo run --locked --quiet -- {{ args }}

# Census what moq-net exports into target/census, from the moq revision `just ts moq` pins or a checkout.
# Census each package moq/ffi.toml exports into target/census/<package>.md, from the moq revision `just ts moq` pins or a checkout.
census checkout="":
#!/usr/bin/env bash
set -euo pipefail
Expand All @@ -42,7 +42,7 @@ census checkout="":
(cd ../ts && just moq-pin)
src=../ts/target/moq
fi
cargo run --locked --quiet -- --config moq/ffi.toml --manifest-path "$src/rs/moq-net/Cargo.toml" --census target/census/moq-net.md
cargo run --locked --quiet -- --config moq/ffi.toml --manifest-path "$src/rs/moq-net/Cargo.toml" --census target/census

# Time frame writes through the export and through moq's hand-written rs/moq-c, in the bench shell: nix develop .#bench.
bench checkout="": (moq checkout)
Expand Down
Loading