From ee9d69b6573331910877ea7fd1836ee3116ba56e Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:52:54 -0700 Subject: [PATCH 01/16] chore(quest): claim quest/m1/ffi-export.md Quest-Claim: e3e42979feb6a40d From 7fbc5dea90007d63fff7a62f58c14b0eaa3ad6a8 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Sun, 25 Oct 2026 22:54:37 -0700 Subject: [PATCH 02/16] chore(quest): record the several-crates decision for the exporter Each crate is its own export with its own census, and an export names another's types across crates. The library layout, C prefixes, and headers are still open. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115AT2ue6x9cT5e5J9SZtCn --- quest/m1/ffi-export.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/quest/m1/ffi-export.md b/quest/m1/ffi-export.md index bd6918e84..0978a3c18 100644 --- a/quest/m1/ffi-export.md +++ b/quest/m1/ffi-export.md @@ -20,8 +20,10 @@ census refuses and what moq waits on: `strip_prefix`, and `Relative::new`, and drops `AsPath` and the `borrow` methods from [the exclude list](/ffi/moq/ffi.toml), refusing only `Path::from_static`'s `'static` borrow. - **Several crates**: moq-tokio's sessions and the media crates hold moq-ffi's remaining API. - Export each with its own census, naming another export's types across crates, as - moq-pattern's re-exports already need. + ✅ Each crate is its own export with its own census, and an export names another's types + across crates: moq-net's `Pattern`, `Patterns`, and `Instant` resolve to moq-pattern's and + moq-clock's exports, not to names at moq-net's re-export path. Still open: the library + layout, the C prefixes, and the headers. - **Driving**: sessions and origins run a `Driver` polled with a time and a `kio::Waiter`. Decide between exporting that poll, a generated thread that drives it, or moq-tokio's runtime helpers. From c3fbbaca773fdf1142e29c03bbd05e4e31e4231a Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:39:18 -0700 Subject: [PATCH 03/16] feat(ffi)!: export several crates into one library Each package the config lists under [crates.] is walked from its crate root, from metadata, in the configured package's rustc session, so an export names another's types across crates. One IR, one glue crate, one prefix, and one header hold every export, and --census takes a directory and writes .md per export. moq-net now exports with moq-pattern and moq-clock, so scope, allowed, auth::Grant, and Frame::arrived name their Patterns and Instant. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115AT2ue6x9cT5e5J9SZtCn --- .github/workflows/nightly.yml | 2 +- driver/src/cargo.rs | 4 +- ffi/Cargo.lock | 9 + ffi/Cargo.toml | 1 + ffi/README.md | 52 +++-- ffi/fixtures/api/Cargo.toml | 1 + ffi/fixtures/api/examples/transcript.rs | 7 +- ffi/fixtures/api/ffi.toml | 3 + ffi/fixtures/api/src/lib.rs | 11 ++ ffi/fixtures/dep/Cargo.toml | 13 ++ ffi/fixtures/dep/src/lib.rs | 29 +++ ffi/fixtures/reject/Cargo.toml | 3 + ffi/fixtures/reject/expected.txt | 9 +- ffi/fixtures/reject/ffi.toml | 3 + ffi/fixtures/reject/src/lib.rs | 13 ++ ffi/justfile | 4 +- ffi/moq/ffi.toml | 43 +++-- ffi/moq/moq.c | 32 +++- ffi/src/abi.rs | 16 +- ffi/src/c.rs | 8 +- ffi/src/census.rs | 62 ++++-- ffi/src/cli.rs | 127 ++++++++++++- ffi/src/collect.rs | 241 ++++++++++++++---------- ffi/src/config.rs | 37 +++- ffi/src/driver.rs | 174 ++++++++++++----- ffi/src/glue.rs | 37 +++- ffi/src/ir.rs | 15 +- ffi/tests/api.c | 8 + ffi/tests/fixtures.rs | 6 +- quest/m1/ffi-export.md | 19 +- 30 files changed, 730 insertions(+), 259 deletions(-) create mode 100644 ffi/fixtures/dep/Cargo.toml create mode 100644 ffi/fixtures/dep/src/lib.rs diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 370aba46a..9ca59ffc9 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -119,7 +119,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 diff --git a/driver/src/cargo.rs b/driver/src/cargo.rs index 0242d333d..348961405 100644 --- a/driver/src/cargo.rs +++ b/driver/src/cargo.rs @@ -5,7 +5,7 @@ use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; /// The command line every driver takes: -/// `--config (--out | --census ) [--manifest-path ] [-- ]`. +/// `--config (--out | --census ) [--manifest-path ] [-- ]`. pub struct Args { /// The driver's config file, absolute. pub config: PathBuf, @@ -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), } diff --git a/ffi/Cargo.lock b/ffi/Cargo.lock index 52824b761..db502630c 100644 --- a/ffi/Cargo.lock +++ b/ffi/Cargo.lock @@ -7,6 +7,7 @@ name = "api-fixture" version = "0.0.0" dependencies = [ "bytes", + "dep-fixture", ] [[package]] @@ -15,6 +16,10 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "dep-fixture" +version = "0.0.0" + [[package]] name = "equivalent" version = "1.0.3" @@ -70,6 +75,9 @@ dependencies = [ [[package]] name = "reject-fixture" version = "0.0.0" +dependencies = [ + "dep-fixture", +] [[package]] name = "rust2-driver" @@ -85,6 +93,7 @@ version = "0.0.0" dependencies = [ "rust2-driver", "serde", + "serde_json", "toml", ] diff --git a/ffi/Cargo.toml b/ffi/Cargo.toml index 327555d90..8da848ed9 100644 --- a/ffi/Cargo.toml +++ b/ffi/Cargo.toml @@ -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] diff --git a/ffi/README.md b/ffi/README.md index 1c74878bd..6481282d2 100644 --- a/ffi/README.md +++ b/ffi/README.md @@ -5,16 +5,18 @@ 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/.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 builds with, 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/.h`; `cargo build` +on that crate makes a static and a shared library. ```toml # ffi.toml, beside the package's Cargo.toml unless --manifest-path says otherwise. @@ -28,13 +30,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 | | --- | --- | @@ -89,17 +97,19 @@ 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` 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; @@ -107,22 +117,24 @@ 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 `.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. diff --git a/ffi/fixtures/api/Cargo.toml b/ffi/fixtures/api/Cargo.toml index aaa48e8eb..b663c5496 100644 --- a/ffi/fixtures/api/Cargo.toml +++ b/ffi/fixtures/api/Cargo.toml @@ -12,6 +12,7 @@ name = "api_lib" [dependencies] bytes = "1.11.1" +dep-fixture = { path = "../dep" } [lints] workspace = true diff --git a/ffi/fixtures/api/examples/transcript.rs b/ffi/fixtures/api/examples/transcript.rs index b0e552271..a9cc83183 100644 --- a/ffi/fixtures/api/examples/transcript.rs +++ b/ffi/fixtures/api/examples/transcript.rs @@ -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; @@ -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); diff --git a/ffi/fixtures/api/ffi.toml b/ffi/fixtures/api/ffi.toml index 54c7836f8..37ee32e19 100644 --- a/ffi/fixtures/api/ffi.toml +++ b/ffi/fixtures/api/ffi.toml @@ -2,3 +2,6 @@ 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] diff --git a/ffi/fixtures/api/src/lib.rs b/ffi/fixtures/api/src/lib.rs index 9e67e2873..a7838933f 100644 --- a/ffi/fixtures/api/src/lib.rs +++ b/ffi/fixtures/api/src/lib.rs @@ -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; @@ -481,3 +483,12 @@ pub fn greet(name: impl Into, 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, + }) +} diff --git a/ffi/fixtures/dep/Cargo.toml b/ffi/fixtures/dep/Cargo.toml new file mode 100644 index 000000000..280cc8256 --- /dev/null +++ b/ffi/fixtures/dep/Cargo.toml @@ -0,0 +1,13 @@ +[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 + +[lib] +name = "dep" + +[lints] +workspace = true diff --git a/ffi/fixtures/dep/src/lib.rs b/ffi/fixtures/dep/src/lib.rs new file mode 100644 index 000000000..cc25d1e31 --- /dev/null +++ b/ffi/fixtures/dep/src/lib.rs @@ -0,0 +1,29 @@ +//! 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 { + pub fn new(size: Size) -> Self { + 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) + } +} diff --git a/ffi/fixtures/reject/Cargo.toml b/ffi/fixtures/reject/Cargo.toml index fc53c130e..4a0f71933 100644 --- a/ffi/fixtures/reject/Cargo.toml +++ b/ffi/fixtures/reject/Cargo.toml @@ -6,5 +6,8 @@ edition = "2024" license = "MIT OR Apache-2.0" publish = false +[dependencies] +dep-fixture = { path = "../dep" } + [lints] workspace = true diff --git a/ffi/fixtures/reject/expected.txt b/ffi/fixtures/reject/expected.txt index 306d7eb4f..5e20aded1 100644 --- a/ffi/fixtures/reject/expected.txt +++ b/ffi/fixtures/reject/expected.txt @@ -1,8 +1,13 @@ +fixtures/dep/src/lib.rs:5:1: rust2-ffi: struct `dep::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::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>`; 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 @@ -11,8 +16,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 diff --git a/ffi/fixtures/reject/ffi.toml b/ffi/fixtures/reject/ffi.toml index 7b8c2daf5..9786be3b0 100644 --- a/ffi/fixtures/reject/ffi.toml +++ b/ffi/fixtures/reject/ffi.toml @@ -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] diff --git a/ffi/fixtures/reject/src/lib.rs b/ffi/fixtures/reject/src/lib.rs index 001c36f29..41cdbb8b1 100644 --- a/ffi/fixtures/reject/src/lib.rs +++ b/ffi/fixtures/reject/src/lib.rs @@ -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; diff --git a/ffi/justfile b/ffi/justfile index 5f40e0e3b..a55080d93 100644 --- a/ffi/justfile +++ b/ffi/justfile @@ -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/.md, from the moq revision `just ts moq` pins or a checkout. census checkout="": #!/usr/bin/env bash set -euo pipefail @@ -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) diff --git a/ffi/moq/ffi.toml b/ffi/moq/ffi.toml index 96bc9a488..d68100c67 100644 --- a/ffi/moq/ffi.toml +++ b/ffi/moq/ffi.toml @@ -17,13 +17,10 @@ exclude = [ "Hops::iter", "IntoBytes", "InvalidEpoch", - "InvalidPattern", "Path::borrow", "Path::new", "Path::parts", "Path::strip_prefix", - "Pattern", - "Patterns", "Server::accept", "Server::accept_request", "Server::with_publisher", @@ -37,16 +34,12 @@ exclude = [ "Versions::iter", "announce::Announce", "announce::Consumer::poll_synced", - "auth::Grant::expires", - "auth::Grant::publish", - "auth::Grant::subscribe", "auth::Issued", "auth::Request", "auth::Requests", "bytes", "cache::Pool::poll", "frame::Buffer", - "frame::Frame::arrived", "frame::Producer::group", "goaway::Consumer", "goaway::Producer", @@ -54,20 +47,12 @@ exclude = [ "group::Consumer::read_frames", "group::Producer::write_frames", "kio", - "origin::Consumer::allowed", - "origin::Consumer::scope", "origin::Driver::poll", - "origin::Producer::allowed", - "origin::Producer::scope", "origin::Requesting", "origin::Routing", "path::Relative::borrow", "path::Relative::new", - "path::Segment", - "path::Specificity", "server::Handshake", - "stats::Config::exclude", - "stats::Config::with_exclude", "stats::Failure::ALL", "stats::RefusalKind::ALL", "stats::Report::sessions", @@ -78,7 +63,6 @@ exclude = [ "stats::Snapshot::traffic", "stats::TrafficEntry", "time::Driver", - "time::Instant", "time::run", "track::Fetching", "track::Querying", @@ -122,3 +106,30 @@ exclude = [ "session::Stats" = "session_stats_snapshot" # The rule makes both this type and `Path::relative` `moq_path_relative`. "path::Relative" = "relative" + +# Exported with moq-net, whose signatures name their types, such as `Patterns` and `Instant`. +[crates.moq-pattern] +exclude = ["Pattern::captures", "Pattern::new", "Pattern::segments", "Patterns::iter"] + +[crates.moq-clock] +exclude = [ + "Deadline::poll", + "Deadlines::enter", + "Deadlines::park", + "Elapsed", + "Entered", + "Instant::from_std", + "Instant::into_std", + "OutOfRange", + "Sleep", + "Timeout", + "drive", + "timeout", + "timeout_at", +] + +[crates.moq-clock.rename] +# The rule makes both this function and the type `Wall` `moq_wall`. +"wall" = "now_wall" +# The rule makes both this function and the type `Interval` `moq_interval`. +"interval" = "interval_every" diff --git a/ffi/moq/moq.c b/ffi/moq/moq.c index 329306dd3..31d267d94 100644 --- a/ffi/moq/moq.c +++ b/ffi/moq/moq.c @@ -1,5 +1,6 @@ /* moq-net's exported C API against the real library: a broadcast's track, published * and subscribed to in one process, with every read polled as a future, and an origin. + * moq-pattern and moq-clock export into the same library, and moq-net names their types. * * It needs no runtime: the waker counts wakes, and each poll that must wait is polled * again after the write that wakes it. */ @@ -85,6 +86,12 @@ int main(void) { moq_group_consumer_read_frame_future_free(reading); CHECK(frame != NULL && is(moq_frame_frame_payload(frame), "hello")); CHECK(moq_frame_frame_timestamp(frame) == NULL); + /* When it arrived: moq-clock's `Instant`, from moq-clock's own export. */ + moq_instant *arrived = moq_frame_frame_arrived(frame); + moq_instant *now = moq_now(); + CHECK(moq_instant_checked_duration_since(now, arrived).some); + moq_instant_free(now); + moq_instant_free(arrived); moq_frame_frame_free(frame); /* A timestamp is a `Copy` value behind a handle: passing it copies it out. The track @@ -131,14 +138,33 @@ int main(void) { moq_path *root = moq_origin_producer_root(origin); CHECK(is(moq_path_as_str(root), "")); moq_path_free(root); - moq_path *absolute = moq_origin_producer_absolute(origin, str("room/alice")); + + /* Scoped beneath a root to patterns, which moq-pattern's own export builds. */ + moq_pattern *alice = NULL; + moq_invalid_pattern invalid; + CHECK(moq_pattern_subtree(str("alice"), &alice, &invalid)); + moq_patterns *patterns = moq_patterns_new(); + CHECK(moq_patterns_insert(patterns, alice)); + moq_pattern_free(alice); + moq_origin_producer *scoped = NULL; + CHECK(moq_origin_producer_scope(origin, str("room"), patterns, &scoped, &err)); + moq_patterns_free(patterns); + root = moq_origin_producer_root(scoped); + CHECK(is(moq_path_as_str(root), "room")); + moq_path_free(root); + moq_path *absolute = moq_origin_producer_absolute(scoped, str("alice")); CHECK(is(moq_path_as_str(absolute), "room/alice")); moq_path_free(absolute); - moq_origin_consumer *reader = moq_origin_producer_consume(origin); - absolute = moq_origin_consumer_absolute(reader, str("room/bob")); + moq_patterns *allowed = moq_origin_producer_allowed(scoped); + CHECK(moq_patterns_matches(allowed, str("alice/cam"))); + CHECK(!moq_patterns_matches(allowed, str("bob"))); + moq_patterns_free(allowed); + moq_origin_consumer *reader = moq_origin_producer_consume(scoped); + absolute = moq_origin_consumer_absolute(reader, str("bob")); CHECK(is(moq_path_as_str(absolute), "room/bob")); moq_path_free(absolute); moq_origin_consumer_free(reader); + moq_origin_producer_free(scoped); /* A broadcast the origin creates is stamped with its path, which its info lends. */ moq_origin_publication *publication = NULL; diff --git a/ffi/src/abi.rs b/ffi/src/abi.rs index 686033768..284e972cd 100644 --- a/ffi/src/abi.rs +++ b/ffi/src/abi.rs @@ -1,11 +1,12 @@ //! The C ABI the IR becomes: every C name, and every function's C signature, decided //! once so the header and the Rust side agree. //! -//! Names follow one rule: the prefix, then the item's path from the crate root, each +//! Names follow one rule: the prefix, then the item's path from its crate's root, each //! segment in snake case, joined by `_`; a method's path goes through its type's. Joining //! loses where segments split (`audio_encoder::finish` and `audio::encoder_finish` are both -//! `moq_audio_encoder_finish`), so every name is registered, and a second item claiming -//! one is a compile error at both spans rather than a silent clash. +//! `moq_audio_encoder_finish`), and every export shares the prefix, so every name is +//! registered, and a second item claiming one is a compile error at both spans rather +//! than a silent clash. use std::collections::{BTreeMap, HashMap}; @@ -69,7 +70,8 @@ pub struct Export { pub struct Abi<'a> { pub api: &'a Api, pub prefix: String, - /// C names for paths, in place of the rule's: an item's, and its methods' through it. + /// C names for Rust paths, each starting with its crate's name, in place of the + /// rule's: an item's, and its methods' through it. rename: &'a BTreeMap, /// The renamed paths some name went through. renamed: std::cell::RefCell>, @@ -99,11 +101,11 @@ impl<'a> Abi<'a> { abi } - /// The C name for a path from the crate root. + /// The C name for a Rust path, which starts with its crate's name. pub fn name(&self, path: &[String]) -> String { let mut name = self.prefix.clone(); - let mut rest = path; - for len in (1..=path.len()).rev() { + let mut rest = &path[1..]; + for len in (2..=path.len()).rev() { let key = path[..len].join("::"); if let Some(renamed) = self.rename.get(&key) { self.renamed.borrow_mut().insert(key); diff --git a/ffi/src/c.rs b/ffi/src/c.rs index ee0ad05bf..b31252c46 100644 --- a/ffi/src/c.rs +++ b/ffi/src/c.rs @@ -6,13 +6,13 @@ use crate::abi::{self, Abi, Sig, Ty}; use crate::ir::{Function, Int, Literal, Receiver, Traits, Type}; /// The header for `abi`, as `.h`. -pub fn header(abi: &Abi<'_>, package: &str) -> String { +pub fn header(abi: &Abi<'_>, packages: &str) -> String { let mut h = Header { abi, out: String::new(), defined: BTreeSet::new(), }; - h.write(package); + h.write(packages); h.out } @@ -66,14 +66,14 @@ impl Header<'_> { format!("{};", self.decl(&sig.ret, &format!("{}({params})", sig.name))) } - fn write(&mut self, package: &str) { + fn write(&mut self, packages: &str) { let abi = self.abi; let api = abi.api; let p = abi.prefix.clone(); let guard = format!("{}_H", p.to_uppercase()); let upper = p.to_uppercase(); self.line(format!( - "/* Generated by rust2-ffi: the C ABI of {package}'s pub API. Do not edit. */\n" + "/* Generated by rust2-ffi: the C ABI of the pub API of {packages}. Do not edit. */\n" )); self.line(format!("#ifndef {guard}\n#define {guard}\n")); self.line("#include \n#include \n#include \n"); diff --git a/ffi/src/census.rs b/ffi/src/census.rs index 4c20bd065..9e251041e 100644 --- a/ffi/src/census.rs +++ b/ffi/src/census.rs @@ -1,5 +1,5 @@ -//! The census: what a crate's `pub` API exports, and every item the export refuses, -//! ranked by why, so the next mapping to build is picked from numbers. +//! The census: what an exported crate's `pub` API exports, and every item the export +//! refuses, ranked by why, so the next mapping to build is picked from numbers. use std::collections::BTreeMap; use std::fmt::Write; @@ -7,19 +7,27 @@ use std::fmt::Write; use rustc_span::Span; use crate::abi::Clash; -use crate::collect::{Collected, Refusal}; +use crate::collect::{Refusal, Report}; +use crate::ir::Api; -/// The census of `krate` as Markdown; `at` locates a span. -pub fn render(krate: &str, collected: &Collected, clashes: &[Clash], at: &dyn Fn(Span) -> String) -> String { - let api = &collected.api; +/// The census of one export as Markdown, `[crates.]` in the config unless it is +/// the configured package; `at` locates a span. +pub fn render(api: &Api, collected: &Report, top: bool, clashes: &[Clash], at: &dyn Fn(Span) -> String) -> String { + let ours = |path: &[String]| path[0] == collected.krate; + let records: Vec<_> = api.records.iter().filter(|r| ours(&r.path)).collect(); + let enums: Vec<_> = api.enums.iter().filter(|e| ours(&e.path)).collect(); + let objects: Vec<_> = api.objects.iter().filter(|o| ours(&o.path)).collect(); + let functions = api.functions.iter().filter(|f| ours(&f.path)).count(); + let callbacks = api.callbacks.iter().filter(|c| ours(&c.path)).count(); + let constants = api.constants.iter().filter(|c| ours(&c.path)).count(); let refused = |kinds: &[&str]| collected.refusals.iter().filter(|r| kinds.contains(&r.kind)).count(); - let methods: usize = api.objects.iter().map(|o| o.methods.len()).sum::() - + api.records.iter().map(|r| r.methods.len()).sum::() - + api.enums.iter().map(|e| e.methods.len()).sum::(); - let types = api.records.len() + api.enums.len() + api.objects.len(); + let methods: usize = objects.iter().map(|o| o.methods.len()).sum::() + + records.iter().map(|r| r.methods.len()).sum::() + + enums.iter().map(|e| e.methods.len()).sum::(); + let types = records.len() + enums.len() + objects.len(); let mut out = String::new(); - let _ = writeln!(out, "# Export census of `{krate}`\n"); + let _ = writeln!(out, "# Export census of `{}`\n", collected.package); let _ = writeln!( out, "| Item | Exported | Refused | Share exported |\n| --- | ---: | ---: | ---: |" @@ -33,21 +41,21 @@ pub fn render(krate: &str, collected: &Collected, clashes: &[Clash], at: &dyn Fn let _ = writeln!(out, "| {name} | {exported} | {refused} | {share} |"); }; row("Types", types, refused(&["struct", "enum", "union"])); - row("Functions", api.functions.len(), refused(&["fn"])); + row("Functions", functions, refused(&["fn"])); row( "Methods and fields of exported types", methods, refused(&["method", "field"]), ); - row("Traits", api.callbacks.len(), refused(&["trait"])); - row("Constants", api.constants.len(), refused(&["const"])); + row("Traits", callbacks, refused(&["trait"])); + row("Constants", constants, refused(&["const"])); row("Other items", 0, refused(&["use", "macro", "static", "item"])); let _ = writeln!( out, "\nExported types: {} records, {} enums, {} objects. A refused type's methods are not counted, nor are the {} `poll_*` methods left out for their async twins.", - api.records.len(), - api.enums.len(), - api.objects.len(), + records.len(), + enums.len(), + objects.len(), collected.twins.len() ); @@ -126,7 +134,11 @@ pub fn render(krate: &str, collected: &Collected, clashes: &[Clash], at: &dyn Fn if !collected.refusals.is_empty() { let _ = writeln!(out, "\n## The config that exports what maps\n"); - let _ = writeln!(out, "```toml\nexclude = ["); + let table = match top { + true => String::new(), + false => format!("[crates.{}]\n", collected.package), + }; + let _ = writeln!(out, "```toml\n{table}exclude = ["); let mut paths: Vec<&str> = collected.refusals.iter().map(|r| r.path.as_str()).collect(); paths.sort(); paths.dedup(); @@ -135,6 +147,11 @@ pub fn render(krate: &str, collected: &Collected, clashes: &[Clash], at: &dyn Fn } let _ = writeln!(out, "]\n```"); } + // A clash names each claimant by its Rust path, which starts with its crate's name. + let claims = |what: &str| what.contains(&format!("`{}::", collected.krate)); + let clashes: Vec<_> = (clashes.iter()) + .filter(|(_, (first, _), (second, _))| claims(first) || claims(second)) + .collect(); if !clashes.is_empty() { let _ = writeln!(out, "\n## C names claimed twice\n"); for (name, (first, _), (second, span)) in clashes { @@ -174,6 +191,15 @@ pub fn render(krate: &str, collected: &Collected, clashes: &[Clash], at: &dyn Fn let _ = writeln!(out, "- `{path}`"); } } + if !collected.reexports.is_empty() { + let _ = writeln!( + out, + "\n## Re-exports of another export's items, which name that export's\n" + ); + for path in &collected.reexports { + let _ = writeln!(out, "- `{path}`"); + } + } out } diff --git a/ffi/src/cli.rs b/ffi/src/cli.rs index 8ffa09670..03f18d41e 100644 --- a/ffi/src/cli.rs +++ b/ffi/src/cli.rs @@ -1,20 +1,21 @@ //! The command line: run cargo on the configured package with this binary as rustc. -use std::process::ExitCode; +use std::path::{Path, PathBuf}; +use std::process::{Command, ExitCode}; use rust2_driver::cargo::{Args, Check, Output}; use crate::config::{self, Config}; -const USAGE: &str = "usage: rust2-ffi --config (--out | --census ) [--manifest-path ] [-- ] +const USAGE: &str = "usage: rust2-ffi --config (--out | --census ) [--manifest-path ] [-- ] -Exports the pub API of the library of the package that ffi.toml names: a crate in -that builds the C ABI, with its C header in /include. The package's Cargo.toml is -beside ffi.toml unless --manifest-path says where. Cargo args, such as --features, are -passed to `cargo check`. +Exports the pub API of the library of the package that ffi.toml names, and of the other +packages it lists, into one library: a crate in that builds the C ABI, with its C +header in /include. The package's Cargo.toml is beside ffi.toml unless +--manifest-path says where. Cargo args, such as --features, are passed to `cargo check`. ---census writes a Markdown report of what exports and of every construct the export -refuses, instead of exporting."; +--census writes a Markdown report per package, /.md, of what exports and +of every construct the export refuses, instead of exporting."; pub fn run(args: &[String]) -> ExitCode { match try_run(args) { @@ -31,6 +32,7 @@ fn try_run(args: &[String]) -> Result { return Ok(ExitCode::SUCCESS); }; let config = Config::load(&args.config)?; + let crates = crates(&args.manifest, &config)?; let output = match &args.output { Output::Dir(out) => (config::env::OUT, out.as_os_str()), Output::Census(census) => (config::env::CENSUS, census.as_os_str()), @@ -42,7 +44,114 @@ fn try_run(args: &[String]) -> Result { // The host's: a C ABI is the platform's. target: None, args: &args.cargo, - env: &[(config::env::CONFIG, args.config.as_os_str()), output], + env: &[ + (config::env::CONFIG, args.config.as_os_str()), + (config::env::CRATES, crates.as_ref()), + output, + ], } .run() } + +/// Each export's package, crate name, and directory, as [`config::env::CRATES`] lists +/// them: the configured package first. +fn crates(manifest: &Path, config: &Config) -> Result { + let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .args(["metadata", "--format-version", "1", "--manifest-path"]) + .arg(manifest) + .output() + .map_err(|err| format!("running cargo metadata: {err}"))?; + if !output.status.success() { + return Err(String::from_utf8_lossy(&output.stderr).into_owned()); + } + let metadata: Metadata = + serde_json::from_slice(&output.stdout).map_err(|err| format!("reading cargo metadata: {err}"))?; + let manifest = std::fs::canonicalize(manifest).map_err(|err| format!("resolving {}: {err}", manifest.display()))?; + let top = (metadata.packages.iter()).find(|p| p.name == config.package && p.manifest_path == manifest); + let Some(top) = top else { + return Err(format!( + "{} is not the package of {}", + config.package, + manifest.display() + )); + }; + // What the configured package's library builds with: a workspace may hold another + // version of a package beside it. + let mut reached = vec![&top.id]; + let mut next = 0; + while let Some(&id) = reached.get(next) { + next += 1; + let node = metadata.resolve.nodes.iter().find(|n| n.id == *id); + for dep in node.into_iter().flat_map(|n| &n.deps) { + if dep.dep_kinds.iter().any(|k| k.kind.is_none()) && !reached.contains(&&dep.pkg) { + reached.push(&dep.pkg); + } + } + } + let mut lines = String::new(); + for name in std::iter::once(&config.package).chain(config.crates.keys()) { + let mut found = (metadata.packages.iter()).filter(|p| p.name == *name && reached.contains(&&p.id)); + let (Some(package), None) = (found.next(), found.next()) else { + return Err(format!( + "{name} is not exactly one package that {} builds with", + config.package + )); + }; + // The glue depends on each by path, so it builds the crate the export checked. + if package.source.is_some() { + return Err(format!("{name} is not a path package, which the export can depend on")); + } + let lib = package.targets.iter().find(|t| t.kind.iter().any(|k| k == "lib")); + let Some(lib) = lib else { + return Err(format!("{name} has no library")); + }; + let dir = package.manifest_path.parent().expect("a manifest path has a parent"); + lines.push_str(&format!("{name}\t{}\t{}\n", lib.name.replace('-', "_"), dir.display())); + } + Ok(lines) +} + +/// What `cargo metadata` says of the packages, and how they depend on each other. +#[derive(serde::Deserialize)] +struct Metadata { + packages: Vec, + resolve: Resolve, +} + +#[derive(serde::Deserialize)] +struct Resolve { + nodes: Vec, +} + +#[derive(serde::Deserialize)] +struct Node { + id: String, + deps: Vec, +} + +#[derive(serde::Deserialize)] +struct Dep { + pkg: String, + dep_kinds: Vec, +} + +#[derive(serde::Deserialize)] +struct DepKind { + /// `dev` or `build`, or none for a normal dependency. + kind: Option, +} + +#[derive(serde::Deserialize)] +struct Package { + id: String, + name: String, + source: Option, + manifest_path: PathBuf, + targets: Vec, +} + +#[derive(serde::Deserialize)] +struct Target { + name: String, + kind: Vec, +} diff --git a/ffi/src/collect.rs b/ffi/src/collect.rs index baf23d427..19c72060a 100644 --- a/ffi/src/collect.rs +++ b/ffi/src/collect.rs @@ -1,14 +1,15 @@ -//! The crate's `pub` API, as rustc checked it, to the [IR](crate::ir). +//! The `pub` API of each exported crate, as rustc checked it, to the [IR](crate::ir). //! -//! Every `pub` item reachable from the crate root is exported by fixed rules, or refused -//! with its span. Nothing is opted in by attribute and nothing is skipped silently: the +//! Every `pub` item reachable from an exported crate's root is exported by fixed rules, or +//! refused with its span. An export names another's types across crates; another crate's +//! type is refused. Nothing is opted in by attribute and nothing is skipped silently: the //! config's `exclude` is the only way to leave an item out, and `#[doc(hidden)]` items, //! which are no part of the documented API, are listed in the census. use std::collections::VecDeque; use rustc_hir::def::{DefKind, Res}; -use rustc_hir::def_id::{CRATE_DEF_ID, DefId}; +use rustc_hir::def_id::{CrateNum, DefId}; use rustc_infer::infer::TyCtxtInferExt; use rustc_infer::traits::{Obligation, ObligationCause}; use rustc_middle::ty::{self, GenericArgKind, Ty, TyCtxt, TypingEnv}; @@ -40,7 +41,7 @@ fn why(construct: &'static str, detail: impl Into) -> Why { /// An item the export refuses, with every reason found. pub struct Refusal { - /// Its path from the crate root. + /// Its path from its crate's root. pub path: String, /// What kind of item it is: `fn`, `method`, `struct`, ... pub kind: &'static str, @@ -51,6 +52,23 @@ pub struct Refusal { /// What collection found. pub struct Collected { pub api: Api, + /// Each export's, in the order given. + pub reports: Vec, +} + +/// An exported crate. +pub struct Crate<'a> { + pub krate: CrateNum, + pub package: &'a str, + /// Paths from its root the config leaves out. + pub exclude: &'a [String], +} + +/// What collection found in one exported crate, by paths from its root. +pub struct Report { + pub package: String, + /// Its crate's name, the first segment of its items' paths in the IR. + pub krate: String, pub refusals: Vec, /// Records and enums exported as objects, since a field does not map: the first. pub opaque: Vec<(String, Why)>, @@ -62,9 +80,11 @@ pub struct Collected { pub excluded: Vec<(String, usize)>, /// Type aliases, which export nothing of their own. pub aliases: Vec, + /// Re-exports of another export's items, which name that export's. + pub reexports: Vec, } -/// How a local type is exported, once decided. +/// How an exported crate's type is exported, once decided. #[derive(Clone)] enum Decl { Record(usize), @@ -79,17 +99,14 @@ struct Collector<'tcx> { /// Each `pub` item and its shortest path, in the order the walk met them. items: Vec<(DefId, Path)>, decls: rustc_data_structures::fx::FxHashMap, - refusals: Vec, - hidden: Vec, - excluded: Vec<(String, usize)>, - aliases: Vec, - /// Local items the walk left out, and why: `excluded` or `doc(hidden)`. + /// The exported crates, the configured one first, and what collection found in each. + crates: Vec, + reports: Vec, + /// Items the walk left out, and why: `excluded` or `doc(hidden)`. left_out: rustc_data_structures::fx::FxHashMap, /// The type parameters of the function being mapped that take a foreign /// implementation, with the callback each takes. impls: std::cell::RefCell, usize)>>, - opaque: Vec<(String, Why)>, - twins: Vec, bytes: Option, /// `std::ops::Bound`, and `std::ops::RangeBounds`, which names it. bound: Option, @@ -99,8 +116,8 @@ struct Collector<'tcx> { api: Api, } -/// Collect the crate's exported API, leaving out what `exclude` names. -pub fn collect<'tcx>(tcx: TyCtxt<'tcx>, exclude: &[String]) -> Collected { +/// Collect the exported crates' API, leaving out what each one's `exclude` names. +pub fn collect<'tcx>(tcx: TyCtxt<'tcx>, crates: &[Crate<'_>]) -> Collected { let bytes = tcx .crates(()) .iter() @@ -131,21 +148,28 @@ pub fn collect<'tcx>(tcx: TyCtxt<'tcx>, exclude: &[String]) -> Collected { tcx, items: Vec::new(), decls: Default::default(), - refusals: Vec::new(), - hidden: Vec::new(), - excluded: exclude.iter().map(|path| (path.clone(), 0)).collect(), - aliases: Vec::new(), + crates: crates.iter().map(|c| c.krate).collect(), + reports: crates + .iter() + .map(|c| Report { + package: c.package.to_string(), + krate: tcx.crate_name(c.krate).to_string(), + refusals: Vec::new(), + opaque: Vec::new(), + twins: Vec::new(), + hidden: Vec::new(), + excluded: c.exclude.iter().map(|path| (path.clone(), 0)).collect(), + aliases: Vec::new(), + reexports: Vec::new(), + }) + .collect(), left_out: Default::default(), impls: Default::default(), - opaque: Vec::new(), - twins: Vec::new(), bytes, bound, range_bounds, orphans: orphans.clone(), api: Api { - // The library's name, which may not be the package's. - krate: tcx.crate_name(rustc_hir::def_id::LOCAL_CRATE).to_string(), records: Vec::new(), enums: Vec::new(), objects: Vec::new(), @@ -161,12 +185,7 @@ pub fn collect<'tcx>(tcx: TyCtxt<'tcx>, exclude: &[String]) -> Collected { if more.is_empty() { return Collected { api: c.api, - refusals: c.refusals, - opaque: c.opaque, - twins: c.twins, - hidden: c.hidden, - excluded: c.excluded, - aliases: c.aliases, + reports: c.reports, }; } orphans.extend(more); @@ -174,10 +193,23 @@ pub fn collect<'tcx>(tcx: TyCtxt<'tcx>, exclude: &[String]) -> Collected { } impl<'tcx> Collector<'tcx> { + /// What collection found in the crate `path` starts with, and the rest of `path`. + fn report(&mut self, path: &[String]) -> (&mut Report, String) { + let report = (self.reports.iter_mut()) + .find(|r| r.krate == path[0]) + .expect("a path starts with its exported crate"); + (report, path[1..].join("::")) + } + + /// Whether `krate` is exported. + fn exported(&self, krate: CrateNum) -> bool { + self.crates.contains(&krate) + } + /// Whether the config excludes `path`, counting the exclusion. fn exclude(&mut self, path: &[String]) -> bool { - let joined = path.join("::"); - for (excluded, count) in &mut self.excluded { + let (report, joined) = self.report(path); + for (excluded, count) in &mut report.excluded { if joined == *excluded || joined.starts_with(&format!("{excluded}::")) { *count += 1; return true; @@ -187,73 +219,85 @@ impl<'tcx> Collector<'tcx> { } fn refuse(&mut self, path: &[String], kind: &'static str, span: Span, why: Vec) { - self.refusals.push(Refusal { - path: path.join("::"), - kind, - span, - why, - }); + let (report, path) = self.report(path); + report.refusals.push(Refusal { path, kind, span, why }); } - /// Every `pub` item reachable from the crate root, breadth first so each keeps its - /// shortest path. + /// Every `pub` item reachable from each exported crate's root, breadth first so each + /// keeps its shortest path, which starts with the crate's name. fn walk(&mut self) { let tcx = self.tcx; - let mut queue = VecDeque::from([(CRATE_DEF_ID, Vec::new())]); let mut seen = rustc_data_structures::fx::FxHashSet::default(); - while let Some((module, path)) = queue.pop_front() { - for child in tcx.module_children_local(module) { - if !child.vis.is_public() || child.ident.name == rustc_span::kw::Underscore { - continue; - } - let Res::Def(kind, def_id) = child.res else { - continue; + for krate in self.crates.clone() { + let mut queue = VecDeque::from([(krate.as_def_id(), vec![tcx.crate_name(krate).to_string()])]); + while let Some((module, path)) = queue.pop_front() { + let children = match module.as_local() { + Some(local) => tcx.module_children_local(local), + None => tcx.module_children(module), }; - // Variants and constructors come along with their type. - if matches!(kind, DefKind::Ctor(..) | DefKind::Variant) { - continue; - } - let mut path = path.clone(); - path.push(child.ident.to_string()); - if !seen.insert(def_id) { - continue; - } - if self.exclude(&path) { - self.left_out.insert(def_id, "excluded type"); - continue; - } - if tcx.is_doc_hidden(def_id) { - self.hidden.push(path.join("::")); - self.left_out.insert(def_id, "doc(hidden) type"); - continue; - } - let span = child - .reexport_chain - .first() - .and_then(|reexport| reexport.id()) - .map_or_else(|| tcx.def_span(def_id), |id| tcx.def_span(id)); - let Some(local) = def_id.as_local() else { - let krate = tcx.crate_name(def_id.krate); - self.refuse( - &path, - "use", - span, - vec![why("re-export of another crate's item", krate.to_string())], - ); - continue; - }; - match kind { - DefKind::Mod => queue.push_back((local, path)), - DefKind::TyAlias => self.aliases.push(path.join("::")), - DefKind::Macro(_) => self.refuse(&path, "macro", span, vec![why("macro", "")]), - DefKind::Static { .. } => self.refuse(&path, "static", span, vec![why("static", "")]), - DefKind::Struct - | DefKind::Enum - | DefKind::Union - | DefKind::Trait - | DefKind::Fn - | DefKind::Const => self.items.push((def_id, path)), - _ => self.refuse(&path, "item", span, vec![why("item kind", format!("{kind:?}"))]), + for child in children { + if !child.vis.is_public() || child.ident.name == rustc_span::kw::Underscore { + continue; + } + let Res::Def(kind, def_id) = child.res else { + continue; + }; + // Variants and constructors come along with their type. + if matches!(kind, DefKind::Ctor(..) | DefKind::Variant) { + continue; + } + let mut path = path.clone(); + path.push(child.ident.to_string()); + // Another export's item, which that export walks from its own root. + if def_id.krate != krate && self.exported(def_id.krate) { + let (report, rest) = self.report(&path); + report.reexports.push(rest); + continue; + } + if !seen.insert(def_id) { + continue; + } + if self.exclude(&path) { + self.left_out.insert(def_id, "excluded type"); + continue; + } + if tcx.is_doc_hidden(def_id) { + let (report, rest) = self.report(&path); + report.hidden.push(rest); + self.left_out.insert(def_id, "doc(hidden) type"); + continue; + } + let span = child + .reexport_chain + .first() + .and_then(|reexport| reexport.id()) + .map_or_else(|| tcx.def_span(def_id), |id| tcx.def_span(id)); + if def_id.krate != krate { + let other = tcx.crate_name(def_id.krate); + self.refuse( + &path, + "use", + span, + vec![why("re-export of another crate's item", other.to_string())], + ); + continue; + } + match kind { + DefKind::Mod => queue.push_back((def_id, path)), + DefKind::TyAlias => { + let (report, rest) = self.report(&path); + report.aliases.push(rest); + } + DefKind::Macro(_) => self.refuse(&path, "macro", span, vec![why("macro", "")]), + DefKind::Static { .. } => self.refuse(&path, "static", span, vec![why("static", "")]), + DefKind::Struct + | DefKind::Enum + | DefKind::Union + | DefKind::Trait + | DefKind::Fn + | DefKind::Const => self.items.push((def_id, path)), + _ => self.refuse(&path, "item", span, vec![why("item kind", format!("{kind:?}"))]), + } } } } @@ -445,7 +489,8 @@ impl<'tcx> Collector<'tcx> { }; if let Err(why) = fields { let first = why.into_iter().next().expect("a refusal has a reason"); - self.opaque.push((path.join("::"), first)); + let (report, rest) = self.report(&path); + report.opaque.push((rest, first)); let decl = self.object(def_id); self.decls.insert(def_id, decl); changed = true; @@ -654,7 +699,8 @@ impl<'tcx> Collector<'tcx> { continue; } if tcx.is_doc_hidden(item.def_id) { - self.hidden.push(method.join("::")); + let (report, rest) = self.report(&method); + report.hidden.push(rest); continue; } let span = tcx.def_span(item.def_id); @@ -675,7 +721,10 @@ impl<'tcx> Collector<'tcx> { .strip_prefix("poll_") .is_some_and(|x| methods.iter().any(|m| m.future && m.name() == x)); match twin { - true => self.twins.push(method.join("::")), + true => { + let (report, rest) = self.report(&method); + report.twins.push(rest); + } false => self.refuse(&method, "method", span, why), } } @@ -1392,7 +1441,7 @@ impl<'tcx> Collector<'tcx> { } _ => return Err(why("Arc", strip_args(tcx, did))), } - } else if !did.is_local() { + } else if !self.exported(did.krate) { return Err(why("foreign type", strip_args(tcx, did))); } else { match self.decls.get(&did) { diff --git a/ffi/src/config.rs b/ffi/src/config.rs index a96ece212..fa5e27c43 100644 --- a/ffi/src/config.rs +++ b/ffi/src/config.rs @@ -1,14 +1,16 @@ //! The `ffi.toml` that says what to export. +use std::collections::BTreeMap; use std::path::Path; use serde::Deserialize; -/// What to export from one crate. +/// What to export: one package's crate, and other packages' it depends on, into one +/// library. #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields, rename_all = "kebab-case")] pub struct Config { - /// The cargo package whose library is exported. + /// The cargo package whose library is checked, and exported first. pub package: String, /// The prefix of every C name, the package's name in snake case by default. pub prefix: Option, @@ -20,13 +22,36 @@ pub struct Config { /// makes clash: `"session::Stats" = "session_stats_snapshot"`. A type's methods are /// named through its new name. #[serde(default)] - pub rename: std::collections::BTreeMap, + pub rename: BTreeMap, + /// Other packages exported into the same library, each a dependency of `package`, by + /// name: an export names another's types across crates. + #[serde(default)] + pub crates: BTreeMap, +} + +/// What leaves another export's items out, and renames them, as `exclude` and `rename` +/// do the package's. +#[derive(Debug, Default, Deserialize)] +#[serde(deny_unknown_fields, rename_all = "kebab-case")] +pub struct Items { + #[serde(default)] + pub exclude: Vec, + #[serde(default)] + pub rename: BTreeMap, } impl Config { pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path).map_err(|err| format!("reading {}: {err}", path.display()))?; - toml::from_str(&text).map_err(|err| format!("parsing {}: {err}", path.display())) + let config: Self = toml::from_str(&text).map_err(|err| format!("parsing {}: {err}", path.display()))?; + if config.crates.contains_key(&config.package) { + return Err(format!( + "{}: [crates.{}] names the package itself, whose `exclude` and `rename` are top-level", + path.display(), + config.package + )); + } + Ok(config) } /// The prefix of every C name. @@ -41,6 +66,8 @@ pub mod env { pub const CONFIG: &str = "RUST2_FFI_CONFIG"; /// The output directory, absolute. pub const OUT: &str = "RUST2_FFI_OUT"; - /// Where to write a census instead of exporting, absolute. + /// The directory to write each export's census into instead of exporting, absolute. pub const CENSUS: &str = "RUST2_FFI_CENSUS"; + /// Each export's package, crate name, and directory, a line each, tab-separated. + pub const CRATES: &str = "RUST2_FFI_CRATES"; } diff --git a/ffi/src/driver.rs b/ffi/src/driver.rs index f3e55236a..096122800 100644 --- a/ffi/src/driver.rs +++ b/ffi/src/driver.rs @@ -1,10 +1,12 @@ //! The binary as rustc: plain for every crate but the configured one, whose API it also -//! collects once rustc has checked it. +//! collects once rustc has checked it, with the API of each other export it depends on. +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::ExitCode; use rustc_driver::{Callbacks, Compilation}; +use rustc_hir::def_id::{CrateNum, LOCAL_CRATE}; use rustc_interface::interface::Compiler; use rustc_middle::ty::TyCtxt; use rustc_span::Span; @@ -26,13 +28,15 @@ struct Export { output: Output, /// The features rustc compiles the crate with, which the export's crate enables. features: Vec, + /// Each export's package, crate name, and directory: the configured package first. + crates: Vec<(String, String, PathBuf)>, } /// What the job writes. enum Output { /// The export, into this directory. Dir(PathBuf), - /// A census of the crate, to this file, instead of an export. + /// A census of each export, into this directory, instead of an export. Census(PathBuf), } @@ -67,10 +71,19 @@ impl Export { .filter_map(|f| f.strip_suffix('"')) .map(str::to_string) .collect(); + let crates = std::env::var(config::env::CRATES) + .map_err(|_| format!("{} is unset", config::env::CRATES))? + .lines() + .map(|line| match line.split('\t').collect::>()[..] { + [package, krate, dir] => Ok((package.to_string(), krate.to_string(), PathBuf::from(dir))), + _ => Err(format!("{} holds a malformed line: {line}", config::env::CRATES)), + }) + .collect::>()?; Ok(Some(Self { config, output, features, + crates, })) } } @@ -80,57 +93,63 @@ impl Callbacks for Export { if tcx.dcx().has_errors().is_some() { return Compilation::Stop; } - // A census counts the whole API, whatever the config leaves out. - let exclude = match self.output { - Output::Census(_) => &[][..], - Output::Dir(_) => &self.config.exclude[..], + let Some(crates) = self.crates(tcx) else { + return Compilation::Stop; }; - let collected = collect::collect(tcx, exclude); - let written = match &self.output { - Output::Census(path) => { - let abi = Abi::new(&collected.api, &self.config.prefix(), &self.config.rename); - for path in abi.unused_renames() { - tcx.dcx() - .err(format!("rust2-ffi: rename `{path}` names nothing exported")); - } - let text = census::render(&self.config.package, &collected, &abi.clashes, &|span| at(tcx, span)); - write(path, &text) + let collected = collect::collect(tcx, &crates); + // Each rename, by the Rust path it names, which starts with its crate's name. + let mut renames = BTreeMap::new(); + for (report, (package, ..)) in collected.reports.iter().zip(&self.crates) { + for (path, name) in self.items(package).1 { + renames.insert(format!("{}::{path}", report.krate), name.clone()); } + } + let abi = Abi::new(&collected.api, &self.config.prefix(), &renames); + for key in abi.unused_renames() { + let (krate, path) = key.split_once("::").expect("a rename starts with its crate"); + tcx.dcx().err(format!( + "rust2-ffi: rename `{path}` in {} names nothing exported", + self.table(&collected, krate) + )); + } + let written = match &self.output { + Output::Census(dir) => collected.reports.iter().enumerate().try_for_each(|(i, report)| { + let text = census::render(&collected.api, report, i == 0, &abi.clashes, &|span| at(tcx, span)); + write(&dir.join(format!("{}.md", report.package)), &text) + }), Output::Dir(out) => { - for refusal in &collected.refusals { - let reasons: Vec = refusal - .why - .iter() - .map(|why| match why.detail.is_empty() { - true => why.construct.to_string(), - false => format!("{} `{}`", why.construct, why.detail), - }) - .collect(); - let mut err = tcx.dcx().struct_span_err( - refusal.span, - format!( - "rust2-ffi: cannot export {} `{}`: {}; exclude it in the config to leave it out", - refusal.kind, - refusal.path, - reasons.join("; ") - ), - ); - for (span, note) in refusal.why.iter().filter_map(|why| why.note.as_ref()) { - err = err.with_span_note(*span, note.clone()); + for report in &collected.reports { + let table = self.table(&collected, &report.krate); + for refusal in &report.refusals { + let reasons: Vec = refusal + .why + .iter() + .map(|why| match why.detail.is_empty() { + true => why.construct.to_string(), + false => format!("{} `{}`", why.construct, why.detail), + }) + .collect(); + let mut err = tcx.dcx().struct_span_err( + refusal.span, + format!( + "rust2-ffi: cannot export {} `{}`: {}; exclude it in {table} to leave it out", + refusal.kind, + refusal.path, + reasons.join("; ") + ), + ); + for (span, note) in refusal.why.iter().filter_map(|why| why.note.as_ref()) { + err = err.with_span_note(*span, note.clone()); + } + err.emit(); } - err.emit(); - } - for (path, count) in &collected.excluded { - if *count == 0 { - tcx.dcx() - .err(format!("rust2-ffi: exclude `{path}` names nothing exported")); + for (path, count) in &report.excluded { + if *count == 0 { + tcx.dcx() + .err(format!("rust2-ffi: exclude `{path}` in {table} names nothing exported")); + } } } - let abi = Abi::new(&collected.api, &self.config.prefix(), &self.config.rename); - for path in abi.unused_renames() { - tcx.dcx() - .err(format!("rust2-ffi: rename `{path}` names nothing exported")); - } for (name, (first, first_span), (second, second_span)) in &abi.clashes { tcx.dcx() .struct_span_err( @@ -144,14 +163,18 @@ impl Callbacks for Export { if tcx.dcx().has_errors().is_some() { return Compilation::Stop; } - let header = c::header(&abi, &self.config.package); - let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").unwrap_or_default(); + let others: Vec<(String, PathBuf)> = (self.crates[1..].iter()) + .map(|(package, _, dir)| (package.clone(), dir.clone())) + .collect(); + let packages: Vec<&str> = self.crates.iter().map(|(package, ..)| package.as_str()).collect(); + let header = c::header(&abi, &packages.join(", ")); let glue = glue::Crate { abi: &abi, header: &header, package: &self.config.package, - manifest_dir: Path::new(&manifest_dir), + manifest_dir: &self.crates[0].2, features: &self.features, + others: &others, }; glue.write(out) } @@ -166,6 +189,57 @@ impl Callbacks for Export { } } +impl Export { + /// Each export's crate, or `None` after an error naming one that is not a dependency. + fn crates<'a>(&'a self, tcx: TyCtxt<'_>) -> Option>> { + let mut crates = Vec::new(); + for (i, (package, krate, _)) in self.crates.iter().enumerate() { + let found: Vec = match i { + 0 => vec![LOCAL_CRATE], + _ => (tcx.crates(()).iter().copied()) + .filter(|&cnum| tcx.crate_name(cnum).as_str() == krate) + .collect(), + }; + // Rust loads only the crates a crate's code names. + let [cnum] = found[..] else { + tcx.dcx().err(format!( + "rust2-ffi: [crates.{package}] needs `{}` to use exactly one crate `{krate}`, and it uses {}", + self.config.package, + found.len() + )); + return None; + }; + crates.push(collect::Crate { + krate: cnum, + package, + // A census counts the whole API, whatever the config leaves out. + exclude: match self.output { + Output::Census(_) => &[], + Output::Dir(_) => self.items(package).0, + }, + }); + } + Some(crates) + } + + /// The config's `exclude` and `rename` for `package`. + fn items(&self, package: &str) -> (&[String], &BTreeMap) { + match self.config.crates.get(package) { + Some(items) => (&items.exclude, &items.rename), + None => (&self.config.exclude, &self.config.rename), + } + } + + /// Where the config says what to export from `krate`. + fn table(&self, collected: &collect::Collected, krate: &str) -> String { + match collected.reports.iter().position(|r| r.krate == krate) { + Some(0) => "the config".to_string(), + Some(i) => format!("the config's [crates.{}]", collected.reports[i].package), + None => unreachable!("every path starts with an exported crate"), + } + } +} + /// Where `span` starts, relative to the crate's directory when inside it. fn at(tcx: TyCtxt<'_>, span: Span) -> String { let sm = tcx.sess.source_map(); diff --git a/ffi/src/glue.rs b/ffi/src/glue.rs index 255d82c19..f540c0a69 100644 --- a/ffi/src/glue.rs +++ b/ffi/src/glue.rs @@ -1,5 +1,5 @@ //! The Rust side of the C ABI: a crate of `extern "C"` functions over the exported -//! crate, with the runtime beside it and the header in `include/`. +//! crates, with the runtime beside it and the header in `include/`. //! //! Each function lifts its C arguments to Rust values, calls the Rust item by its path, //! and lowers the result. A value is copied in from the caller's memory, which Rust @@ -7,7 +7,7 @@ //! receiver or parameter is, or moved out of when taken by value. use std::fmt::Write; -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::abi::{self, Abi, Ty}; use crate::ir::{Access, Function, Mutability, Receiver, Traits, Type}; @@ -42,15 +42,27 @@ pub struct Crate<'a> { pub package: &'a str, pub manifest_dir: &'a Path, pub features: &'a [String], + /// The other exports' packages and directories, each with the features `package` + /// enables in it. + pub others: &'a [(String, PathBuf)], } impl Crate<'_> { + /// The exported packages, comma-separated. + fn packages(&self) -> String { + let others = self.others.iter().map(|(package, _)| package.as_str()); + std::iter::once(self.package) + .chain(others) + .collect::>() + .join(", ") + } + /// Write the crate into `out`: `Cargo.toml`, `src/`, and `include/.h`. pub fn write(&self, out: &Path) -> Result<(), String> { let mut features: Vec = self.features.iter().map(|f| format!("{f:?}")).collect(); features.sort(); let manifest = format!( - "# Generated by rust2-ffi: the C ABI of {package}'s pub API.\n\ + "# Generated by rust2-ffi: the C ABI of the pub API of {packages}.\n\ [package]\n\ name = \"{package}-ffi\"\n\ version = \"0.0.0\"\n\ @@ -63,17 +75,25 @@ impl Crate<'_> { \n\ [dependencies]\n\ {package} = {{ path = {path:?}, default-features = false, features = [{features}] }}\n\ + {others}\ bytes = \"1\"\n\ \n\ [workspace]\n", package = self.package, + packages = self.packages(), lib = self.abi.prefix, path = self.manifest_dir.display().to_string(), features = features.join(", "), + others = (self.others.iter()) + .map(|(package, dir)| format!( + "{package} = {{ path = {:?}, default-features = false }}\n", + dir.display().to_string() + )) + .collect::(), ); let mut generator = Gen { abi: self.abi, - krate: self.abi.api.krate.clone(), + packages: self.packages(), out: String::new(), }; generator.lib(); @@ -86,7 +106,8 @@ impl Crate<'_> { struct Gen<'a> { abi: &'a Abi<'a>, - krate: String, + /// The exported packages, for the docs. + packages: String, out: String, } @@ -97,7 +118,7 @@ impl Gen<'_> { } fn path(&self, path: &[String]) -> String { - format!("{}::{}", self.krate, path.join("::")) + path.join("::") } /// An object's Rust type: its lifetimes `'static`, as the handle holds it. @@ -266,8 +287,8 @@ impl Gen<'_> { fn lib(&mut self) { let api = self.abi.api; self.line(format!( - "//! Generated by rust2-ffi: the C ABI of `{}`'s pub API. Do not edit.\n", - api.krate + "//! Generated by rust2-ffi: the C ABI of the pub API of {}. Do not edit.\n", + self.packages )); self.line("#![allow(non_camel_case_types, reason = \"the C names\")]"); self.line("#![allow(unused_unsafe, reason = \"every conversion is in its own block\")]"); diff --git a/ffi/src/ir.rs b/ffi/src/ir.rs index 9b263b546..3917996bc 100644 --- a/ffi/src/ir.rs +++ b/ffi/src/ir.rs @@ -1,15 +1,13 @@ -//! The exported API, as each generator reads it: what the crate's `pub` items became, -//! with no language's names or calling convention decided yet. +//! The exported API, as each generator reads it: what the exported crates' `pub` items +//! became, with no language's names or calling convention decided yet. //! -//! Every item keeps its path from the crate root, so a generator names it by its own -//! rule, and its span, so a generator refuses it with one. +//! Every item keeps its Rust path, so a generator names it by its own rule, and its span, +//! so a generator refuses it with one. use rustc_span::Span; -/// A crate's exported API. +/// The exported crates' API. pub struct Api { - /// The crate's name, as Rust code names it: `moq_net`. - pub krate: String, pub records: Vec, pub enums: Vec, pub objects: Vec, @@ -18,7 +16,8 @@ pub struct Api { pub constants: Vec, } -/// A path from the crate root: `["track", "Producer", "append_group"]`. +/// A Rust path: its crate's name, then the path from that crate's root, such as +/// `["moq_net", "track", "Producer", "append_group"]`. pub type Path = Vec; /// A struct whose fields are all `pub`: a value, copied across. diff --git a/ffi/tests/api.c b/ffi/tests/api.c index e53738f52..c6a38daac 100644 --- a/ffi/tests/api.c +++ b/ffi/tests/api.c @@ -293,6 +293,14 @@ static int script(void) { printf("both %u\n", api_bump_both(tally, tally)); api_tally_free(tally); + /* Another export's types, which this export's function takes and returns. */ + api_area *small = api_area_new((api_size){.width = 2, .height = 3}); + api_area *area = api_grow(small, 1); + api_area_free(small); + api_size size = api_area_size(area); + printf("area %u %u %llu\n", size.width, size.height, (unsigned long long)api_area_pixels(area)); + api_area_free(area); + /* Its Drop runs when C frees the handle, not before. */ api_token *token = api_token_new(7); printf("token %u dropped %u\n", api_token_id(token), api_dropped()); diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index 54fea389f..950848586 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -113,7 +113,7 @@ fn api() { fn relative_target() { let dir = scratch().join("relative"); std::fs::create_dir_all(&dir).unwrap(); - let census = dir.join("census.md"); + let census = dir.join("census"); let output = Command::new(env!("CARGO_BIN_EXE_rust2-ffi")) .arg("--config") .arg(manifest_dir().join("fixtures/api/ffi.toml")) @@ -128,7 +128,9 @@ fn relative_target() { "the census failed:\n{}", String::from_utf8_lossy(&output.stderr) ); - assert!(census.exists() && dir.join("build").exists()); + // One census per export. + assert!(census.join("api-fixture.md").exists() && census.join("dep-fixture.md").exists()); + assert!(dir.join("build").exists()); } #[test] diff --git a/quest/m1/ffi-export.md b/quest/m1/ffi-export.md index 0978a3c18..68ea66c2e 100644 --- a/quest/m1/ffi-export.md +++ b/quest/m1/ffi-export.md @@ -19,11 +19,20 @@ census refuses and what moq waits on: makes `Path` owned. Once it lands, bump the moq pin: the census then exports `Path::new`, `strip_prefix`, and `Relative::new`, and drops `AsPath` and the `borrow` methods from [the exclude list](/ffi/moq/ffi.toml), refusing only `Path::from_static`'s `'static` borrow. -- **Several crates**: moq-tokio's sessions and the media crates hold moq-ffi's remaining API. - ✅ Each crate is its own export with its own census, and an export names another's types - across crates: moq-net's `Pattern`, `Patterns`, and `Instant` resolve to moq-pattern's and - moq-clock's exports, not to names at moq-net's re-export path. Still open: the library - layout, the C prefixes, and the headers. +- **Several crates**: moq-tokio's sessions and the media crates (hang, moq-mux) hold + moq-ffi's remaining API. Export them as moq-pattern and moq-clock already export beside + moq-net, each under `[crates.]` in [the config](/ffi/moq/ffi.toml). The checked + package must build with every export, so this needs moq-ffi's remainder, or a crate + standing in for it, as that package. Decided with the maintainer: + - ✅ Each crate is its own export with its own census, and an export names another's + types across crates, not names at a re-export's path. + - ✅ One rustc session collects every export from metadata: one IR, one glue crate, one + library. + - ✅ One prefix per library, with names from each crate's root and clashes checked across + exports. + - ✅ One header, `include/.h`. + - ✅ One `ffi.toml`, with a `[crates.]` table per other export, and + `--census ` writing `.md` for each. - **Driving**: sessions and origins run a `Driver` polled with a time and a `kio::Waiter`. Decide between exporting that poll, a generated thread that drives it, or moq-tokio's runtime helpers. From 74dc8514a03ed5035141d54a1140340e6fbdbcc0 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 26 Oct 2026 09:57:56 -0700 Subject: [PATCH 04/16] fix(ffi): build each export as the check did The C library's crate now enables what `--features /` enabled in another export, and refuses such a feature on a dependency that is no export. cargo metadata runs from the package's directory with the check's feature args, and accepts an explicit rlib. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115AT2ue6x9cT5e5J9SZtCn --- ffi/README.md | 4 +- ffi/fixtures/dep/Cargo.toml | 7 +++ ffi/fixtures/dep/src/lib.rs | 6 ++ ffi/src/cli.rs | 107 +++++++++++++++++++++++++++++++++--- ffi/src/config.rs | 42 +++++++++++++- ffi/src/driver.rs | 54 +++++++----------- ffi/src/glue.rs | 50 ++++++++--------- ffi/tests/fixtures.rs | 29 ++++++++-- 8 files changed, 225 insertions(+), 74 deletions(-) diff --git a/ffi/README.md b/ffi/README.md index 6481282d2..8d399e324 100644 --- a/ffi/README.md +++ b/ffi/README.md @@ -16,7 +16,9 @@ It walks each other package the config lists the same way, from the metadata of crate the library builds with, 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/.h`; `cargo build` -on that crate makes a static and a shared library. +on that crate makes a static and a shared library. That crate enables each export's +features as the check did, including `--features /`; 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. diff --git a/ffi/fixtures/dep/Cargo.toml b/ffi/fixtures/dep/Cargo.toml index 280cc8256..5730d145d 100644 --- a/ffi/fixtures/dep/Cargo.toml +++ b/ffi/fixtures/dep/Cargo.toml @@ -6,8 +6,15 @@ 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 diff --git a/ffi/fixtures/dep/src/lib.rs b/ffi/fixtures/dep/src/lib.rs index cc25d1e31..9ff746bf9 100644 --- a/ffi/fixtures/dep/src/lib.rs +++ b/ffi/fixtures/dep/src/lib.rs @@ -14,7 +14,13 @@ pub struct Area { } 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 } } diff --git a/ffi/src/cli.rs b/ffi/src/cli.rs index 03f18d41e..cdcb0c6eb 100644 --- a/ffi/src/cli.rs +++ b/ffi/src/cli.rs @@ -1,5 +1,6 @@ //! The command line: run cargo on the configured package with this binary as rustc. +use std::collections::BTreeMap; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; @@ -32,7 +33,7 @@ fn try_run(args: &[String]) -> Result { return Ok(ExitCode::SUCCESS); }; let config = Config::load(&args.config)?; - let crates = crates(&args.manifest, &config)?; + let crates = crates(&args.manifest, &config, &args.cargo)?; let output = match &args.output { Output::Dir(out) => (config::env::OUT, out.as_os_str()), Output::Census(census) => (config::env::CENSUS, census.as_os_str()), @@ -53,12 +54,17 @@ fn try_run(args: &[String]) -> Result { .run() } -/// Each export's package, crate name, and directory, as [`config::env::CRATES`] lists -/// them: the configured package first. -fn crates(manifest: &Path, config: &Config) -> Result { +/// Each export's package, as [`config::env::CRATES`] lists them: the configured package +/// first. +fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result { + let picks = Features::parse(cargo); let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + // Cargo reads its config from where it runs, as the check does. + .current_dir(manifest.parent().expect("a manifest path has a parent")) .args(["metadata", "--format-version", "1", "--manifest-path"]) .arg(manifest) + // The features the check picks decide which optional dependencies it builds with. + .args(picks.args()) .output() .map_err(|err| format!("running cargo metadata: {err}"))?; if !output.status.success() { @@ -88,6 +94,7 @@ fn crates(manifest: &Path, config: &Config) -> Result { } } } + let features = picks.of_exports(top, config)?; let mut lines = String::new(); for name in std::iter::once(&config.package).chain(config.crates.keys()) { let mut found = (metadata.packages.iter()).filter(|p| p.name == *name && reached.contains(&&p.id)); @@ -101,16 +108,91 @@ fn crates(manifest: &Path, config: &Config) -> Result { if package.source.is_some() { return Err(format!("{name} is not a path package, which the export can depend on")); } - let lib = package.targets.iter().find(|t| t.kind.iter().any(|k| k == "lib")); + let lib = (package.targets.iter()).find(|t| t.kind.iter().any(|k| matches!(k.as_str(), "lib" | "rlib"))); let Some(lib) = lib else { return Err(format!("{name} has no library")); }; - let dir = package.manifest_path.parent().expect("a manifest path has a parent"); - lines.push_str(&format!("{name}\t{}\t{}\n", lib.name.replace('-', "_"), dir.display())); + let package = config::Package { + name: name.clone(), + krate: lib.name.replace('-', "_"), + dir: package + .manifest_path + .parent() + .expect("a manifest path has a parent") + .into(), + features: features.get(name.as_str()).cloned().unwrap_or_default(), + }; + lines.push_str(&package.line()); } Ok(lines) } +/// The features the cargo args pick for the check. +struct Features<'a> { + /// Each `--features` and `-F` names. + named: Vec<&'a str>, + /// `--all-features` and `--no-default-features`. + flags: Vec<&'a str>, +} + +impl<'a> Features<'a> { + fn parse(cargo: &'a [String]) -> Self { + let mut picks = Self { + named: Vec::new(), + flags: Vec::new(), + }; + let mut args = cargo.iter().map(String::as_str); + while let Some(arg) = args.next() { + let value = match arg { + "--all-features" | "--no-default-features" => { + picks.flags.push(arg); + continue; + } + "--features" | "-F" => args.next(), + _ => arg.strip_prefix("--features=").or_else(|| arg.strip_prefix("-F")), + }; + let named = value.into_iter().flat_map(|v| v.split([',', ' '])); + picks.named.extend(named.filter(|f| !f.is_empty())); + } + picks + } + + /// The same picks, as cargo args. + fn args(&self) -> Vec { + let mut args: Vec = self.flags.iter().map(|f| f.to_string()).collect(); + if !self.named.is_empty() { + args.extend(["--features".to_string(), self.named.join(",")]); + } + args + } + + /// The features named in another export directly, as `/`, by + /// package. Cargo unifies in the rest through the configured package's own. + fn of_exports(&self, top: &'a Package, config: &Config) -> Result>, String> { + let mut features: BTreeMap<&str, Vec> = BTreeMap::new(); + for feature in &self.named { + let Some((key, name)) = feature.split_once('/') else { + continue; + }; + let key = key.strip_suffix('?').unwrap_or(key); + let dep = + (top.dependencies.iter()).find(|d| d.kind.is_none() && d.rename.as_deref().unwrap_or(&d.name) == key); + // Cargo refuses a feature of no dependency. + let Some(dep) = dep else { + continue; + }; + if !config.crates.contains_key(&dep.name) { + return Err(format!( + "--features {feature}: the export builds {} with only the features {} enables in it, unless it is an export too", + dep.name, config.package + )); + } + features.entry(&dep.name).or_default().push(name.to_string()); + } + Ok(features) + } +} + /// What `cargo metadata` says of the packages, and how they depend on each other. #[derive(serde::Deserialize)] struct Metadata { @@ -148,6 +230,17 @@ struct Package { source: Option, manifest_path: PathBuf, targets: Vec, + dependencies: Vec, +} + +#[derive(serde::Deserialize)] +struct Dependency { + /// The package's name. + name: String, + /// The name the dependent calls it by, when not the package's. + rename: Option, + /// `dev` or `build`, or none for a normal dependency. + kind: Option, } #[derive(serde::Deserialize)] diff --git a/ffi/src/config.rs b/ffi/src/config.rs index fa5e27c43..3f3cd95ee 100644 --- a/ffi/src/config.rs +++ b/ffi/src/config.rs @@ -1,7 +1,7 @@ //! The `ffi.toml` that says what to export. use std::collections::BTreeMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use serde::Deserialize; @@ -60,6 +60,44 @@ impl Config { } } +/// An export's package, as the CLI resolves it for the wrapper. +pub struct Package { + pub name: String, + /// Its library's crate name. + pub krate: String, + pub dir: PathBuf, + /// The features the generated crate enables in it, beyond what the configured + /// package's enable. + pub features: Vec, +} + +impl Package { + /// One line of [`env::CRATES`]. + pub fn line(&self) -> String { + let dir = self.dir.display(); + format!("{}\t{}\t{dir}\t{}\n", self.name, self.krate, self.features.join(",")) + } + + /// The packages [`env::CRATES`] lists, a line each. + pub fn parse(lines: &str) -> Result, String> { + (lines.lines()) + .map(|line| match line.split('\t').collect::>()[..] { + [name, krate, dir, features] => Ok(Self { + name: name.to_string(), + krate: krate.to_string(), + dir: PathBuf::from(dir), + features: features + .split(',') + .filter(|f| !f.is_empty()) + .map(str::to_string) + .collect(), + }), + _ => Err(format!("{} holds a malformed line: {line}", env::CRATES)), + }) + .collect() + } +} + /// Environment variables the CLI hands the wrapper. pub mod env { /// The `ffi.toml` path, absolute. @@ -68,6 +106,6 @@ pub mod env { pub const OUT: &str = "RUST2_FFI_OUT"; /// The directory to write each export's census into instead of exporting, absolute. pub const CENSUS: &str = "RUST2_FFI_CENSUS"; - /// Each export's package, crate name, and directory, a line each, tab-separated. + /// Each export's [`Package`](super::Package), a line each. pub const CRATES: &str = "RUST2_FFI_CRATES"; } diff --git a/ffi/src/driver.rs b/ffi/src/driver.rs index 096122800..ad73f2279 100644 --- a/ffi/src/driver.rs +++ b/ffi/src/driver.rs @@ -13,7 +13,7 @@ use rustc_span::Span; use crate::abi::Abi; use crate::config::{self, Config}; -use crate::{c, census, collect, glue}; +use crate::{census, collect, glue}; /// Run as rustc, with `args` as cargo passed them: the wrapped rustc's path first. pub fn run(args: Vec) -> ExitCode { @@ -26,10 +26,9 @@ pub fn run(args: Vec) -> ExitCode { struct Export { config: Config, output: Output, - /// The features rustc compiles the crate with, which the export's crate enables. - features: Vec, - /// Each export's package, crate name, and directory: the configured package first. - crates: Vec<(String, String, PathBuf)>, + /// Each export's package: the configured package first, with the features rustc + /// compiles it with. + crates: Vec, } /// What the job writes. @@ -64,27 +63,19 @@ impl Export { )); } }; - let features = args + let crates = std::env::var(config::env::CRATES).map_err(|_| format!("{} is unset", config::env::CRATES))?; + let mut crates = config::Package::parse(&crates)?; + let Some(top) = crates.first_mut() else { + return Err(format!("{} lists no package", config::env::CRATES)); + }; + top.features = args .windows(2) .filter(|w| w[0] == "--cfg") .filter_map(|w| w[1].strip_prefix("feature=\"")) .filter_map(|f| f.strip_suffix('"')) .map(str::to_string) .collect(); - let crates = std::env::var(config::env::CRATES) - .map_err(|_| format!("{} is unset", config::env::CRATES))? - .lines() - .map(|line| match line.split('\t').collect::>()[..] { - [package, krate, dir] => Ok((package.to_string(), krate.to_string(), PathBuf::from(dir))), - _ => Err(format!("{} holds a malformed line: {line}", config::env::CRATES)), - }) - .collect::>()?; - Ok(Some(Self { - config, - output, - features, - crates, - })) + Ok(Some(Self { config, output, crates })) } } @@ -99,8 +90,8 @@ impl Callbacks for Export { let collected = collect::collect(tcx, &crates); // Each rename, by the Rust path it names, which starts with its crate's name. let mut renames = BTreeMap::new(); - for (report, (package, ..)) in collected.reports.iter().zip(&self.crates) { - for (path, name) in self.items(package).1 { + for (report, package) in collected.reports.iter().zip(&self.crates) { + for (path, name) in self.items(&package.name).1 { renames.insert(format!("{}::{path}", report.krate), name.clone()); } } @@ -163,18 +154,9 @@ impl Callbacks for Export { if tcx.dcx().has_errors().is_some() { return Compilation::Stop; } - let others: Vec<(String, PathBuf)> = (self.crates[1..].iter()) - .map(|(package, _, dir)| (package.clone(), dir.clone())) - .collect(); - let packages: Vec<&str> = self.crates.iter().map(|(package, ..)| package.as_str()).collect(); - let header = c::header(&abi, &packages.join(", ")); let glue = glue::Crate { abi: &abi, - header: &header, - package: &self.config.package, - manifest_dir: &self.crates[0].2, - features: &self.features, - others: &others, + exports: &self.crates, }; glue.write(out) } @@ -193,7 +175,13 @@ impl Export { /// Each export's crate, or `None` after an error naming one that is not a dependency. fn crates<'a>(&'a self, tcx: TyCtxt<'_>) -> Option>> { let mut crates = Vec::new(); - for (i, (package, krate, _)) in self.crates.iter().enumerate() { + for ( + i, + config::Package { + name: package, krate, .. + }, + ) in self.crates.iter().enumerate() + { let found: Vec = match i { 0 => vec![LOCAL_CRATE], _ => (tcx.crates(()).iter().copied()) diff --git a/ffi/src/glue.rs b/ffi/src/glue.rs index f540c0a69..3e0ff5ce7 100644 --- a/ffi/src/glue.rs +++ b/ffi/src/glue.rs @@ -7,9 +7,10 @@ //! receiver or parameter is, or moved out of when taken by value. use std::fmt::Write; -use std::path::{Path, PathBuf}; +use std::path::Path; use crate::abi::{self, Abi, Ty}; +use crate::config::Package; use crate::ir::{Access, Function, Mutability, Receiver, Traits, Type}; const RUNTIME: &str = include_str!("../runtime/rt.rs"); @@ -38,29 +39,32 @@ fn rust(ty: &Type) -> String { /// The crate that builds the C ABI. pub struct Crate<'a> { pub abi: &'a Abi<'a>, - pub header: &'a str, - pub package: &'a str, - pub manifest_dir: &'a Path, - pub features: &'a [String], - /// The other exports' packages and directories, each with the features `package` - /// enables in it. - pub others: &'a [(String, PathBuf)], + /// The exported packages, the configured one first, each a dependency of the crate. + pub exports: &'a [Package], } impl Crate<'_> { /// The exported packages, comma-separated. fn packages(&self) -> String { - let others = self.others.iter().map(|(package, _)| package.as_str()); - std::iter::once(self.package) - .chain(others) - .collect::>() - .join(", ") + let names: Vec<&str> = self.exports.iter().map(|p| p.name.as_str()).collect(); + names.join(", ") } /// Write the crate into `out`: `Cargo.toml`, `src/`, and `include/.h`. pub fn write(&self, out: &Path) -> Result<(), String> { - let mut features: Vec = self.features.iter().map(|f| format!("{f:?}")).collect(); - features.sort(); + // Cargo unifies in what the configured package enables in each other export. + let dependencies: String = (self.exports.iter()) + .map(|export| { + let mut features: Vec = export.features.iter().map(|f| format!("{f:?}")).collect(); + features.sort(); + format!( + "{} = {{ path = {:?}, default-features = false, features = [{}] }}\n", + export.name, + export.dir.display().to_string(), + features.join(", ") + ) + }) + .collect(); let manifest = format!( "# Generated by rust2-ffi: the C ABI of the pub API of {packages}.\n\ [package]\n\ @@ -74,23 +78,15 @@ impl Crate<'_> { crate-type = [\"staticlib\", \"cdylib\"]\n\ \n\ [dependencies]\n\ - {package} = {{ path = {path:?}, default-features = false, features = [{features}] }}\n\ - {others}\ + {dependencies}\ bytes = \"1\"\n\ \n\ [workspace]\n", - package = self.package, + package = self.exports[0].name, packages = self.packages(), lib = self.abi.prefix, - path = self.manifest_dir.display().to_string(), - features = features.join(", "), - others = (self.others.iter()) - .map(|(package, dir)| format!( - "{package} = {{ path = {:?}, default-features = false }}\n", - dir.display().to_string() - )) - .collect::(), ); + let header = crate::c::header(self.abi, &self.packages()); let mut generator = Gen { abi: self.abi, packages: self.packages(), @@ -100,7 +96,7 @@ impl Crate<'_> { crate::driver::write(&out.join("Cargo.toml"), &manifest)?; crate::driver::write(&out.join("src/lib.rs"), &generator.out)?; crate::driver::write(&out.join("src/rt.rs"), RUNTIME)?; - crate::driver::write(&out.join("include").join(format!("{}.h", self.abi.prefix)), self.header) + crate::driver::write(&out.join("include").join(format!("{}.h", self.abi.prefix)), &header) } } diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index 950848586..17c12f0c6 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -28,14 +28,17 @@ fn run(cmd: &mut Command) -> Output { output } -/// Export the fixture `name` into `out`, returning what the exporter printed. -fn export(name: &str, out: &Path) -> Output { +/// Export the fixture `name` into `out` with these cargo args, returning what the +/// exporter printed. +fn export(name: &str, out: &Path, cargo: &[&str]) -> Output { let fixture = manifest_dir().join("fixtures").join(name); Command::new(env!("CARGO_BIN_EXE_rust2-ffi")) .arg("--config") .arg(fixture.join("ffi.toml")) .arg("--out") .arg(out) + .arg("--") + .args(cargo) .env("CARGO_TARGET_DIR", scratch().join(name).join("build")) .output() .expect("running rust2-ffi") @@ -44,7 +47,9 @@ fn export(name: &str, out: &Path) -> Output { #[test] fn api() { let out = scratch().join("api").join("out"); - let output = export("api", &out); + // A feature enabled on another export directly, which the C library must build with. + let features = ["--features", "dep-fixture/doubled"]; + let output = export("api", &out, &features); assert!( output.status.success(), "exporting failed:\n{}", @@ -82,6 +87,7 @@ fn api() { "--example", "transcript", ]) + .args(features) .current_dir(manifest_dir())); assert_eq!( String::from_utf8_lossy(&c.stdout), @@ -136,7 +142,7 @@ fn relative_target() { #[test] fn reject() { let out = scratch().join("reject").join("out"); - let output = export("reject", &out); + let output = export("reject", &out, &[]); assert!(!output.status.success(), "exporting reject succeeded"); let stderr = String::from_utf8_lossy(&output.stderr); // Each error at its span, then each of its notes at its own. @@ -174,3 +180,18 @@ fn reject() { } assert_eq!(errors.join("\n") + "\n", expected); } + +/// The C library builds a dependency with what the check enabled in it, so a feature +/// enabled directly in one that is no export fails loud. +#[test] +fn feature_of_non_export() { + let out = scratch().join("feature").join("out"); + let output = export("api", &out, &["--features", "bytes/std"]); + assert!(!output.status.success(), "exporting with bytes/std succeeded"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr + .contains("--features bytes/std: the export builds bytes with only the features api-fixture enables in it"), + "{stderr}" + ); +} From 21503fb80dd042f2661701613a5627ad79c15498 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:10:54 -0700 Subject: [PATCH 05/16] fix(ffi): refuse each export's re-exports on their own Two exports re-exporting one item shared a visited set, so the second skipped it silently. `-F=` now reads as `-F `. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115AT2ue6x9cT5e5J9SZtCn --- ffi/fixtures/api/ffi.toml | 1 + ffi/fixtures/dep/src/lib.rs | 4 ++++ ffi/fixtures/reject/expected.txt | 1 + ffi/src/cli.rs | 28 +++++++++++++++++++++++++++- ffi/src/collect.rs | 3 ++- 5 files changed, 35 insertions(+), 2 deletions(-) diff --git a/ffi/fixtures/api/ffi.toml b/ffi/fixtures/api/ffi.toml index 37ee32e19..31fc01185 100644 --- a/ffi/fixtures/api/ffi.toml +++ b/ffi/fixtures/api/ffi.toml @@ -5,3 +5,4 @@ exclude = ["Node::next"] # Exported into the same library: `grow` names its types. [crates.dep-fixture] +exclude = ["HashSet"] diff --git a/ffi/fixtures/dep/src/lib.rs b/ffi/fixtures/dep/src/lib.rs index 9ff746bf9..4e52e5e22 100644 --- a/ffi/fixtures/dep/src/lib.rs +++ b/ffi/fixtures/dep/src/lib.rs @@ -33,3 +33,7 @@ impl Area { 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; diff --git a/ffi/fixtures/reject/expected.txt b/ffi/fixtures/reject/expected.txt index 5e20aded1..d1df3f962 100644 --- a/ffi/fixtures/reject/expected.txt +++ b/ffi/fixtures/reject/expected.txt @@ -1,3 +1,4 @@ +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::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::Size` and struct `reject_fixture::Size` are both `reject_size` in C diff --git a/ffi/src/cli.rs b/ffi/src/cli.rs index cdcb0c6eb..101b6033f 100644 --- a/ffi/src/cli.rs +++ b/ffi/src/cli.rs @@ -149,7 +149,8 @@ impl<'a> Features<'a> { continue; } "--features" | "-F" => args.next(), - _ => arg.strip_prefix("--features=").or_else(|| arg.strip_prefix("-F")), + _ => (arg.strip_prefix("--features=")) + .or_else(|| arg.strip_prefix("-F").map(|v| v.strip_prefix('=').unwrap_or(v))), }; let named = value.into_iter().flat_map(|v| v.split([',', ' '])); picks.named.extend(named.filter(|f| !f.is_empty())); @@ -248,3 +249,28 @@ struct Target { name: String, kind: Vec, } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn reads_each_spelling_of_features() { + for spelling in [ + &["--features", "a,dep/b"][..], + &["--features=a dep/b"], + &["-F", "a", "-F", "dep/b"], + &["-Fa,dep/b"], + &["-F=a", "--features", "dep/b"], + ] { + let cargo: Vec = spelling.iter().map(|s| s.to_string()).collect(); + assert_eq!( + Features::parse(&cargo).args(), + ["--features", "a,dep/b"], + "{spelling:?}" + ); + } + let cargo = ["--all-features".to_string(), "--locked".to_string()]; + assert_eq!(Features::parse(&cargo).args(), ["--all-features"]); + } +} diff --git a/ffi/src/collect.rs b/ffi/src/collect.rs index 19c72060a..7898ffc02 100644 --- a/ffi/src/collect.rs +++ b/ffi/src/collect.rs @@ -227,8 +227,9 @@ impl<'tcx> Collector<'tcx> { /// keeps its shortest path, which starts with the crate's name. fn walk(&mut self) { let tcx = self.tcx; - let mut seen = rustc_data_structures::fx::FxHashSet::default(); for krate in self.crates.clone() { + // Per export: two may re-export one item, and each refuses or excludes it. + let mut seen = rustc_data_structures::fx::FxHashSet::default(); let mut queue = VecDeque::from([(krate.as_def_id(), vec![tcx.crate_name(krate).to_string()])]); while let Some((module, path)) = queue.pop_front() { let children = match module.as_local() { From 84785d12dabd0eb5c49417cafa550be88ee88ff3 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:26:21 -0700 Subject: [PATCH 06/16] fix(ffi): find exports under the check's lockfile and network flags cargo metadata now gets `--locked`, `--frozen`, `--offline`, and `--config` from the cargo args, so it no longer writes a lockfile that a `--locked` check then accepts. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115AT2ue6x9cT5e5J9SZtCn --- ffi/src/cli.rs | 46 ++++++++++++++++++++++++++++--------------- ffi/tests/fixtures.rs | 27 +++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 16 deletions(-) diff --git a/ffi/src/cli.rs b/ffi/src/cli.rs index 101b6033f..debd63f2e 100644 --- a/ffi/src/cli.rs +++ b/ffi/src/cli.rs @@ -57,13 +57,14 @@ fn try_run(args: &[String]) -> Result { /// Each export's package, as [`config::env::CRATES`] lists them: the configured package /// first. fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result { - let picks = Features::parse(cargo); + let picks = Picks::parse(cargo); let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) // Cargo reads its config from where it runs, as the check does. .current_dir(manifest.parent().expect("a manifest path has a parent")) .args(["metadata", "--format-version", "1", "--manifest-path"]) .arg(manifest) - // The features the check picks decide which optional dependencies it builds with. + // As the check resolves: its features decide which optional dependencies it builds + // with, and its lockfile and network flags what cargo may change and fetch. .args(picks.args()) .output() .map_err(|err| format!("running cargo metadata: {err}"))?; @@ -127,15 +128,15 @@ fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result { - /// Each `--features` and `-F` names. +/// What the cargo args pick for how the check resolves its dependencies. +struct Picks<'a> { + /// Each feature `--features` and `-F` name. named: Vec<&'a str>, - /// `--all-features` and `--no-default-features`. + /// The other args that pick a resolution: feature, lockfile, network, and `--config`. flags: Vec<&'a str>, } -impl<'a> Features<'a> { +impl<'a> Picks<'a> { fn parse(cargo: &'a [String]) -> Self { let mut picks = Self { named: Vec::new(), @@ -144,7 +145,15 @@ impl<'a> Features<'a> { let mut args = cargo.iter().map(String::as_str); while let Some(arg) = args.next() { let value = match arg { - "--all-features" | "--no-default-features" => { + "--all-features" | "--no-default-features" | "--locked" | "--frozen" | "--offline" => { + picks.flags.push(arg); + continue; + } + "--config" => { + picks.flags.extend([Some(arg), args.next()].into_iter().flatten()); + continue; + } + _ if arg.starts_with("--config=") => { picks.flags.push(arg); continue; } @@ -255,7 +264,7 @@ mod tests { use super::*; #[test] - fn reads_each_spelling_of_features() { + fn reads_what_picks_a_resolution() { for spelling in [ &["--features", "a,dep/b"][..], &["--features=a dep/b"], @@ -264,13 +273,18 @@ mod tests { &["-F=a", "--features", "dep/b"], ] { let cargo: Vec = spelling.iter().map(|s| s.to_string()).collect(); - assert_eq!( - Features::parse(&cargo).args(), - ["--features", "a,dep/b"], - "{spelling:?}" - ); + assert_eq!(Picks::parse(&cargo).args(), ["--features", "a,dep/b"], "{spelling:?}"); } - let cargo = ["--all-features".to_string(), "--locked".to_string()]; - assert_eq!(Features::parse(&cargo).args(), ["--all-features"]); + let cargo = [ + "--all-features", + "--locked", + "--config", + "a=1", + "--config=b=2", + "--quiet", + ]; + let cargo: Vec = cargo.iter().map(|s| s.to_string()).collect(); + let flags = ["--all-features", "--locked", "--config", "a=1", "--config=b=2"]; + assert_eq!(Picks::parse(&cargo).args(), flags); } } diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index 17c12f0c6..05387cc07 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -195,3 +195,30 @@ fn feature_of_non_export() { "{stderr}" ); } + +/// Finding the exports keeps to the check's `--locked`: it neither writes a lockfile the +/// check then accepts, nor passes. +#[test] +fn locked() { + let package = scratch().join("locked"); + std::fs::create_dir_all(package.join("src")).unwrap(); + std::fs::write( + package.join("Cargo.toml"), + "[package]\nname = \"locked\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n[workspace]\n", + ) + .unwrap(); + std::fs::write(package.join("src/lib.rs"), "pub fn one() -> u32 {\n\t1\n}\n").unwrap(); + std::fs::write(package.join("ffi.toml"), "package = \"locked\"\n").unwrap(); + let _ = std::fs::remove_file(package.join("Cargo.lock")); + let output = Command::new(env!("CARGO_BIN_EXE_rust2-ffi")) + .arg("--config") + .arg(package.join("ffi.toml")) + .arg("--census") + .arg(package.join("census")) + .args(["--", "--locked"]) + .env("CARGO_TARGET_DIR", package.join("build")) + .output() + .expect("running rust2-ffi"); + assert!(!output.status.success(), "a census without a lockfile passed --locked"); + assert!(!package.join("Cargo.lock").exists(), "the lockfile was written"); +} From 38e1e1cf63fe086d2a758a4fa4458aef25e569d5 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:39:56 -0700 Subject: [PATCH 07/16] fix(ffi): find exports on the host, through symlinks cargo metadata now filters to the host the check builds for, so a version that only another platform depends on no longer makes an export ambiguous. The configured package matches by canonical path, since cargo keeps a symlink's spelling. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115AT2ue6x9cT5e5J9SZtCn --- ffi/src/cli.rs | 6 ++- ffi/src/main.rs | 1 + ffi/tests/fixtures.rs | 98 +++++++++++++++++++++++++++++++++++-------- 3 files changed, 86 insertions(+), 19 deletions(-) diff --git a/ffi/src/cli.rs b/ffi/src/cli.rs index debd63f2e..ef0403ce2 100644 --- a/ffi/src/cli.rs +++ b/ffi/src/cli.rs @@ -63,6 +63,8 @@ fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result Result PathBuf { + let package = scratch().join(name); std::fs::create_dir_all(package.join("src")).unwrap(); - std::fs::write( - package.join("Cargo.toml"), - "[package]\nname = \"locked\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n[workspace]\n", - ) - .unwrap(); + let manifest = format!("[package]\nname = \"{name}\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n[workspace]\n"); + std::fs::write(package.join("Cargo.toml"), manifest).unwrap(); std::fs::write(package.join("src/lib.rs"), "pub fn one() -> u32 {\n\t1\n}\n").unwrap(); - std::fs::write(package.join("ffi.toml"), "package = \"locked\"\n").unwrap(); - let _ = std::fs::remove_file(package.join("Cargo.lock")); - let output = Command::new(env!("CARGO_BIN_EXE_rust2-ffi")) + std::fs::write(package.join("ffi.toml"), format!("package = \"{name}\"\n")).unwrap(); + package +} + +/// Run a census of the package whose config is `config` with these cargo args. +fn census(config: &Path, cargo: &[&str]) -> Output { + let dir = config.parent().unwrap(); + Command::new(env!("CARGO_BIN_EXE_rust2-ffi")) .arg("--config") - .arg(package.join("ffi.toml")) + .arg(config) .arg("--census") - .arg(package.join("census")) - .args(["--", "--locked"]) - .env("CARGO_TARGET_DIR", package.join("build")) + .arg(dir.join("census")) + .arg("--") + .args(cargo) + .env("CARGO_TARGET_DIR", dir.join("build")) .output() - .expect("running rust2-ffi"); + .expect("running rust2-ffi") +} + +/// Finding the exports keeps to the check's `--locked`: it neither writes a lockfile the +/// check then accepts, nor passes. +#[test] +fn locked() { + let package = package("locked"); + let _ = std::fs::remove_file(package.join("Cargo.lock")); + let output = census(&package.join("ffi.toml"), &["--locked"]); assert!(!output.status.success(), "a census without a lockfile passed --locked"); assert!(!package.join("Cargo.lock").exists(), "the lockfile was written"); } + +/// A package reached through a symlinked directory is the same package, though cargo +/// keeps the link in its paths. +#[test] +fn symlinked() { + let package = package("symlinked"); + let link = scratch().join("symlinked-link"); + if !link.exists() { + std::os::unix::fs::symlink(&package, &link).unwrap(); + } + let output = census(&link.join("ffi.toml"), &[]); + assert!( + output.status.success(), + "the census failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} + +/// Another export is found among what the check builds on the host: a version only +/// another platform depends on is no rival. +#[test] +fn other_platform() { + let package = package("platform"); + for (dir, version) in [("unix", "1.0.0"), ("windows", "2.0.0")] { + let dep = package.join(dir); + std::fs::create_dir_all(dep.join("src")).unwrap(); + let manifest = format!("[package]\nname = \"platform-dep\"\nversion = \"{version}\"\nedition = \"2024\"\n"); + std::fs::write(dep.join("Cargo.toml"), manifest).unwrap(); + std::fs::write(dep.join("src/lib.rs"), "pub struct Size {\n\tpub width: u32,\n}\n").unwrap(); + } + let manifest = "[package]\nname = \"platform\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n\ + [target.'cfg(unix)'.dependencies]\nunix = { package = \"platform-dep\", path = \"unix\" }\n\n\ + [target.'cfg(windows)'.dependencies]\nwindows = { package = \"platform-dep\", path = \"windows\" }\n\n\ + [workspace]\nexclude = [\"unix\", \"windows\"]\n"; + std::fs::write(package.join("Cargo.toml"), manifest).unwrap(); + std::fs::write( + package.join("src/lib.rs"), + "#[cfg(unix)]\nuse unix as dep;\n#[cfg(windows)]\nuse windows as dep;\n\npub fn width(size: dep::Size) -> u32 {\n\tsize.width\n}\n", + ) + .unwrap(); + std::fs::write( + package.join("ffi.toml"), + "package = \"platform\"\n\n[crates.platform-dep]\n", + ) + .unwrap(); + let output = census(&package.join("ffi.toml"), &[]); + assert!( + output.status.success(), + "the census failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} From 0dc0a5b6afba3f047c9aabadc191cf70d9fde562 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 26 Oct 2026 12:50:05 -0700 Subject: [PATCH 08/16] fix(ffi): check for the host the exports are found on The check named no target, so cargo's `build.target` or `CARGO_BUILD_TARGET` could check another platform than metadata was filtered to. It now passes the host's `--target`, and a `--target` among the cargo args is an error. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115AT2ue6x9cT5e5J9SZtCn --- ffi/src/cli.rs | 31 +++++++++++++++++++++++-------- ffi/tests/fixtures.rs | 26 ++++++++++++++------------ 2 files changed, 37 insertions(+), 20 deletions(-) diff --git a/ffi/src/cli.rs b/ffi/src/cli.rs index ef0403ce2..fcef641c9 100644 --- a/ffi/src/cli.rs +++ b/ffi/src/cli.rs @@ -13,7 +13,8 @@ const USAGE: &str = "usage: rust2-ffi --config (--out | --censu Exports the pub API of the library of the package that ffi.toml names, and of the other packages it lists, into one library: a crate in that builds the C ABI, with its C header in /include. The package's Cargo.toml is beside ffi.toml unless ---manifest-path says where. Cargo args, such as --features, are passed to `cargo check`. +--manifest-path says where. Cargo args, such as --features, are passed to `cargo check`, +on the host. --census writes a Markdown report per package, /.md, of what exports and of every construct the export refuses, instead of exporting."; @@ -42,8 +43,9 @@ fn try_run(args: &[String]) -> Result { tool: "rust2-ffi", manifest: &args.manifest, package: &config.package, - // The host's: a C ABI is the platform's. - target: None, + // The host's, whatever cargo's config names: a C ABI is the platform's, and the + // exports are found on it. + target: Some(rustc_session::config::host_tuple()), args: &args.cargo, env: &[ (config::env::CONFIG, args.config.as_os_str()), @@ -57,7 +59,7 @@ fn try_run(args: &[String]) -> Result { /// Each export's package, as [`config::env::CRATES`] lists them: the configured package /// first. fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result { - let picks = Picks::parse(cargo); + let picks = Picks::parse(cargo)?; let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) // Cargo reads its config from where it runs, as the check does. .current_dir(manifest.parent().expect("a manifest path has a parent")) @@ -141,7 +143,7 @@ struct Picks<'a> { } impl<'a> Picks<'a> { - fn parse(cargo: &'a [String]) -> Self { + fn parse(cargo: &'a [String]) -> Result { let mut picks = Self { named: Vec::new(), flags: Vec::new(), @@ -153,6 +155,8 @@ impl<'a> Picks<'a> { picks.flags.push(arg); continue; } + "--target" => return Err("--target: the export builds for the host".to_string()), + _ if arg.starts_with("--target=") => return Err(format!("{arg}: the export builds for the host")), "--config" => { picks.flags.extend([Some(arg), args.next()].into_iter().flatten()); continue; @@ -168,7 +172,7 @@ impl<'a> Picks<'a> { let named = value.into_iter().flat_map(|v| v.split([',', ' '])); picks.named.extend(named.filter(|f| !f.is_empty())); } - picks + Ok(picks) } /// The same picks, as cargo args. @@ -277,7 +281,11 @@ mod tests { &["-F=a", "--features", "dep/b"], ] { let cargo: Vec = spelling.iter().map(|s| s.to_string()).collect(); - assert_eq!(Picks::parse(&cargo).args(), ["--features", "a,dep/b"], "{spelling:?}"); + assert_eq!( + Picks::parse(&cargo).unwrap().args(), + ["--features", "a,dep/b"], + "{spelling:?}" + ); } let cargo = [ "--all-features", @@ -289,6 +297,13 @@ mod tests { ]; let cargo: Vec = cargo.iter().map(|s| s.to_string()).collect(); let flags = ["--all-features", "--locked", "--config", "a=1", "--config=b=2"]; - assert_eq!(Picks::parse(&cargo).args(), flags); + assert_eq!(Picks::parse(&cargo).unwrap().args(), flags); + for target in [ + &["--target", "wasm32-unknown-unknown"][..], + &["--target=wasm32-unknown-unknown"], + ] { + let cargo: Vec = target.iter().map(|s| s.to_string()).collect(); + assert!(Picks::parse(&cargo).is_err(), "{target:?}"); + } } } diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index debd73aca..7310130a3 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -207,19 +207,18 @@ fn package(name: &str) -> PathBuf { package } -/// Run a census of the package whose config is `config` with these cargo args. -fn census(config: &Path, cargo: &[&str]) -> Output { +/// A census of the package whose config is `config` with these cargo args, to run. +fn census(config: &Path, cargo: &[&str]) -> Command { let dir = config.parent().unwrap(); - Command::new(env!("CARGO_BIN_EXE_rust2-ffi")) - .arg("--config") + let mut cmd = Command::new(env!("CARGO_BIN_EXE_rust2-ffi")); + cmd.arg("--config") .arg(config) .arg("--census") .arg(dir.join("census")) .arg("--") .args(cargo) - .env("CARGO_TARGET_DIR", dir.join("build")) - .output() - .expect("running rust2-ffi") + .env("CARGO_TARGET_DIR", dir.join("build")); + cmd } /// Finding the exports keeps to the check's `--locked`: it neither writes a lockfile the @@ -228,7 +227,7 @@ fn census(config: &Path, cargo: &[&str]) -> Output { fn locked() { let package = package("locked"); let _ = std::fs::remove_file(package.join("Cargo.lock")); - let output = census(&package.join("ffi.toml"), &["--locked"]); + let output = census(&package.join("ffi.toml"), &["--locked"]).output().unwrap(); assert!(!output.status.success(), "a census without a lockfile passed --locked"); assert!(!package.join("Cargo.lock").exists(), "the lockfile was written"); } @@ -242,7 +241,7 @@ fn symlinked() { if !link.exists() { std::os::unix::fs::symlink(&package, &link).unwrap(); } - let output = census(&link.join("ffi.toml"), &[]); + let output = census(&link.join("ffi.toml"), &[]).output().unwrap(); assert!( output.status.success(), "the census failed:\n{}", @@ -250,8 +249,8 @@ fn symlinked() { ); } -/// Another export is found among what the check builds on the host: a version only -/// another platform depends on is no rival. +/// Another export is found among what the check builds on the host, whatever target +/// cargo's config names: a version only another platform depends on is no rival. #[test] fn other_platform() { let package = package("platform"); @@ -277,7 +276,10 @@ fn other_platform() { "package = \"platform\"\n\n[crates.platform-dep]\n", ) .unwrap(); - let output = census(&package.join("ffi.toml"), &[]); + let output = census(&package.join("ffi.toml"), &[]) + .env("CARGO_BUILD_TARGET", "x86_64-pc-windows-msvc") + .output() + .unwrap(); assert!( output.status.success(), "the census failed:\n{}", From 07e7d5433855f0bff98456cb5eb5d8c7d72813f0 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:14:08 -0700 Subject: [PATCH 09/16] fix(ffi): find exports in the library's own dependency graph cargo metadata's graph unifies the features dev-dependencies enable, so a version that only they pull in made an export ambiguous. `cargo tree --edges normal` on the host resolves as the library check does; metadata now only describes the packages. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115AT2ue6x9cT5e5J9SZtCn --- ffi/src/cli.rs | 114 +++++++++++++++++++----------------------- ffi/tests/fixtures.rs | 47 +++++++++++++++++ 2 files changed, 98 insertions(+), 63 deletions(-) diff --git a/ffi/src/cli.rs b/ffi/src/cli.rs index fcef641c9..1394f1816 100644 --- a/ffi/src/cli.rs +++ b/ffi/src/cli.rs @@ -1,6 +1,6 @@ //! The command line: run cargo on the configured package with this binary as rustc. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use std::path::{Path, PathBuf}; use std::process::{Command, ExitCode}; @@ -60,27 +60,13 @@ fn try_run(args: &[String]) -> Result { /// first. fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result { let picks = Picks::parse(cargo)?; - let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) - // Cargo reads its config from where it runs, as the check does. - .current_dir(manifest.parent().expect("a manifest path has a parent")) - .args(["metadata", "--format-version", "1", "--manifest-path"]) - .arg(manifest) - // The check's platform, the host: another's dependencies are no rival to an export. - .args(["--filter-platform", rustc_session::config::host_tuple()]) - // As the check resolves: its features decide which optional dependencies it builds - // with, and its lockfile and network flags what cargo may change and fetch. - .args(picks.args()) - .output() - .map_err(|err| format!("running cargo metadata: {err}"))?; - if !output.status.success() { - return Err(String::from_utf8_lossy(&output.stderr).into_owned()); - } - let metadata: Metadata = - serde_json::from_slice(&output.stdout).map_err(|err| format!("reading cargo metadata: {err}"))?; - let manifest = std::fs::canonicalize(manifest).map_err(|err| format!("resolving {}: {err}", manifest.display()))?; + let output = run_cargo(manifest, &["metadata", "--format-version", "1"], &picks)?; + let metadata: Metadata = serde_json::from_slice(&output).map_err(|err| format!("reading cargo metadata: {err}"))?; + let canonical = + std::fs::canonicalize(manifest).map_err(|err| format!("resolving {}: {err}", manifest.display()))?; // Cargo keeps a symlink's spelling of the path. let top = (metadata.packages.iter()) - .find(|p| p.name == config.package && std::fs::canonicalize(&p.manifest_path).is_ok_and(|p| p == manifest)); + .find(|p| p.name == config.package && std::fs::canonicalize(&p.manifest_path).is_ok_and(|p| p == canonical)); let Some(top) = top else { return Err(format!( "{} is not the package of {}", @@ -88,33 +74,42 @@ fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result v`, then + // its directory in parentheses for a path package. Metadata's graph would also hold + // what dev-dependencies' features add, and a workspace may hold another version of a + // package beside it. + let tree = [ + "tree", + "--edges", + "normal", + "--target", + rustc_session::config::host_tuple(), + "--prefix", + "none", + "--format", + "{p}", + ]; + let output = run_cargo(manifest, &tree, &picks)?; + let output = String::from_utf8(output).map_err(|err| format!("reading cargo tree: {err}"))?; + let reached: BTreeSet<&str> = output.lines().map(|line| line.trim_end_matches(" (*)")).collect(); let features = picks.of_exports(top, config)?; let mut lines = String::new(); for name in std::iter::once(&config.package).chain(config.crates.keys()) { - let mut found = (metadata.packages.iter()).filter(|p| p.name == *name && reached.contains(&&p.id)); - let (Some(package), None) = (found.next(), found.next()) else { + let mut found = reached.iter().filter(|p| p.split(' ').next() == Some(name.as_str())); + let (Some(&found), None) = (found.next(), found.next()) else { return Err(format!( "{name} is not exactly one package that {} builds with", config.package )); }; // The glue depends on each by path, so it builds the crate the export checked. - if package.source.is_some() { + let package = (metadata.packages.iter()).find(|p| { + let dir = p.manifest_path.parent().expect("a manifest path has a parent"); + p.source.is_none() && found == format!("{} v{} ({})", p.name, p.version, dir.display()) + }); + let Some(package) = package else { return Err(format!("{name} is not a path package, which the export can depend on")); - } + }; let lib = (package.targets.iter()).find(|t| t.kind.iter().any(|k| matches!(k.as_str(), "lib" | "rlib"))); let Some(lib) = lib else { return Err(format!("{name} has no library")); @@ -134,6 +129,23 @@ fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result) -> Result, String> { + let output = Command::new(std::env::var_os("CARGO").unwrap_or_else(|| "cargo".into())) + .current_dir(manifest.parent().expect("a manifest path has a parent")) + .args(subcommand) + .arg("--manifest-path") + .arg(manifest) + .args(picks.args()) + .output() + .map_err(|err| format!("running cargo {}: {err}", subcommand[0]))?; + match output.status.success() { + true => Ok(output.stdout), + false => Err(String::from_utf8_lossy(&output.stderr).into_owned()), + } +} + /// What the cargo args pick for how the check resolves its dependencies. struct Picks<'a> { /// Each feature `--features` and `-F` name. @@ -211,40 +223,16 @@ impl<'a> Picks<'a> { } } -/// What `cargo metadata` says of the packages, and how they depend on each other. +/// What `cargo metadata` says of the packages. #[derive(serde::Deserialize)] struct Metadata { packages: Vec, - resolve: Resolve, -} - -#[derive(serde::Deserialize)] -struct Resolve { - nodes: Vec, -} - -#[derive(serde::Deserialize)] -struct Node { - id: String, - deps: Vec, -} - -#[derive(serde::Deserialize)] -struct Dep { - pkg: String, - dep_kinds: Vec, -} - -#[derive(serde::Deserialize)] -struct DepKind { - /// `dev` or `build`, or none for a normal dependency. - kind: Option, } #[derive(serde::Deserialize)] struct Package { - id: String, name: String, + version: String, source: Option, manifest_path: PathBuf, targets: Vec, diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index 7310130a3..676597217 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -286,3 +286,50 @@ fn other_platform() { String::from_utf8_lossy(&output.stderr) ); } + +/// Another export is found among what the library builds with: a version only a +/// dev-dependency's features pull in is no rival. +#[test] +fn dev_features() { + let package = package("dev"); + let write = |path: &str, text: &str| { + let path = package.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + }; + for (dir, version) in [("exp1", "1.0.0"), ("exp2", "2.0.0")] { + write( + &format!("{dir}/Cargo.toml"), + &format!("[package]\nname = \"dev-exp\"\nversion = \"{version}\"\nedition = \"2024\"\n"), + ); + write( + &format!("{dir}/src/lib.rs"), + "pub struct Size {\n\tpub width: u32,\n}\n", + ); + } + write( + "route/Cargo.toml", + "[package]\nname = \"dev-route\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n\ + [dependencies]\nexp2 = { package = \"dev-exp\", path = \"../exp2\", optional = true }\n\n\ + [features]\nextra = [\"dep:exp2\"]\n", + ); + write("route/src/lib.rs", ""); + write( + "Cargo.toml", + "[package]\nname = \"dev\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n\ + [dependencies]\nexp = { package = \"dev-exp\", path = \"exp1\" }\nroute = { package = \"dev-route\", path = \"route\" }\n\n\ + [dev-dependencies]\nroute = { package = \"dev-route\", path = \"route\", features = [\"extra\"] }\n\n\ + [workspace]\nexclude = [\"exp1\", \"exp2\", \"route\"]\n", + ); + write( + "src/lib.rs", + "pub fn width(size: exp::Size) -> u32 {\n\tsize.width\n}\n", + ); + write("ffi.toml", "package = \"dev\"\n\n[crates.dev-exp]\n"); + let output = census(&package.join("ffi.toml"), &[]).output().unwrap(); + assert!( + output.status.success(), + "the census failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} From 14a79e43edeb0d47c8eeb9f8e2c2a68a75b04f1f Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:24:58 -0700 Subject: [PATCH 10/16] fix(ffi): read the checked package's tree, uncolored `cargo tree` now names the checked package, as the check does, rather than the workspace's default members, and prints no color, which kept a repeat's `(*)` from reading as one package. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115AT2ue6x9cT5e5J9SZtCn --- ffi/src/cli.rs | 6 +++++ ffi/tests/fixtures.rs | 60 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/ffi/src/cli.rs b/ffi/src/cli.rs index 1394f1816..7ae0b1310 100644 --- a/ffi/src/cli.rs +++ b/ffi/src/cli.rs @@ -80,6 +80,9 @@ fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result Result u32 {\n\tsize.width\n}\n", + ); + write("ffi.toml", "package = \"diamond\"\n\n[crates.diamond-shared]\n"); + let output = census(&package.join("ffi.toml"), &[]) + .env("CARGO_TERM_COLOR", "always") + .output() + .unwrap(); + assert!( + output.status.success(), + "the census failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} From 5c174329abe658c9ca08ccce508d2e74531a6dad Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 26 Oct 2026 13:47:16 -0700 Subject: [PATCH 11/16] fix(ffi): bind each export to its package's crate, not its crate name An unused export let a used dependency whose crate shared its name stand in for it, while the glue built the export. Each export is now bound by its library's root file, and the runtime's cross-thread shared borrows require `Sync`. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115AT2ue6x9cT5e5J9SZtCn --- ffi/runtime/rt.rs | 12 +++++++++--- ffi/src/cli.rs | 2 ++ ffi/src/config.rs | 15 ++++++++++++--- ffi/src/driver.rs | 24 ++++++++++++++++++++--- ffi/tests/fixtures.rs | 44 +++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 88 insertions(+), 9 deletions(-) diff --git a/ffi/runtime/rt.rs b/ffi/runtime/rt.rs index f692db1df..e47486186 100644 --- a/ffi/runtime/rt.rs +++ b/ffi/runtime/rt.rs @@ -228,7 +228,7 @@ fn thread() -> usize { } // SAFETY: the value moves between threads only by `take` or an exclusive borrow, and is -// shared only by shared borrows, which the export takes only of `Sync` types. +// shared across threads only by `borrow` and `borrow_arc`, which require `Sync`. unsafe impl Send for Object {} // SAFETY: as above. unsafe impl Sync for Object {} @@ -291,7 +291,10 @@ impl Object { /// /// # Safety /// `handle` is a live handle of this type, kept alive through the borrow. - pub unsafe fn borrow<'a>(handle: *const c_void, what: &str) -> Shared<'a, T> { + pub unsafe fn borrow<'a>(handle: *const c_void, what: &str) -> Shared<'a, T> + where + T: Sync, + { // SAFETY: the caller's contract. let object = unsafe { Self::get(handle, what) }; object.acquire(what); @@ -334,7 +337,10 @@ impl Object { /// /// # Safety /// `handle` is a live handle of this type. - pub unsafe fn borrow_arc(handle: *const c_void, what: &str) -> SharedArc { + pub unsafe fn borrow_arc(handle: *const c_void, what: &str) -> SharedArc + where + T: Sync, + { // SAFETY: the caller's contract. let object = unsafe { Self::arc(handle, what) }; object.acquire(what); diff --git a/ffi/src/cli.rs b/ffi/src/cli.rs index 7ae0b1310..1e78516ee 100644 --- a/ffi/src/cli.rs +++ b/ffi/src/cli.rs @@ -128,6 +128,7 @@ fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result, + src_path: PathBuf, } #[cfg(test)] diff --git a/ffi/src/config.rs b/ffi/src/config.rs index 3f3cd95ee..246b7ebfc 100644 --- a/ffi/src/config.rs +++ b/ffi/src/config.rs @@ -66,6 +66,9 @@ pub struct Package { /// Its library's crate name. pub krate: String, pub dir: PathBuf, + /// Its library's root file, which binds it to the crate rustc loaded: another + /// package's crate may share its name. + pub root: PathBuf, /// The features the generated crate enables in it, beyond what the configured /// package's enable. pub features: Vec, @@ -74,18 +77,24 @@ pub struct Package { impl Package { /// One line of [`env::CRATES`]. pub fn line(&self) -> String { - let dir = self.dir.display(); - format!("{}\t{}\t{dir}\t{}\n", self.name, self.krate, self.features.join(",")) + let (dir, root) = (self.dir.display(), self.root.display()); + format!( + "{}\t{}\t{dir}\t{root}\t{}\n", + self.name, + self.krate, + self.features.join(",") + ) } /// The packages [`env::CRATES`] lists, a line each. pub fn parse(lines: &str) -> Result, String> { (lines.lines()) .map(|line| match line.split('\t').collect::>()[..] { - [name, krate, dir, features] => Ok(Self { + [name, krate, dir, root, features] => Ok(Self { name: name.to_string(), krate: krate.to_string(), dir: PathBuf::from(dir), + root: PathBuf::from(root), features: features .split(',') .filter(|f| !f.is_empty()) diff --git a/ffi/src/driver.rs b/ffi/src/driver.rs index ad73f2279..511bc4f75 100644 --- a/ffi/src/driver.rs +++ b/ffi/src/driver.rs @@ -178,21 +178,29 @@ impl Export { for ( i, config::Package { - name: package, krate, .. + name: package, + krate, + root, + .. }, ) in self.crates.iter().enumerate() { + // Bound by its root file: another package's crate may share its name. + let root = std::fs::canonicalize(root).ok(); let found: Vec = match i { 0 => vec![LOCAL_CRATE], _ => (tcx.crates(()).iter().copied()) - .filter(|&cnum| tcx.crate_name(cnum).as_str() == krate) + .filter(|&cnum| { + tcx.crate_name(cnum).as_str() == krate && root.is_some() && root_file(tcx, cnum) == root + }) .collect(), }; // Rust loads only the crates a crate's code names. let [cnum] = found[..] else { tcx.dcx().err(format!( - "rust2-ffi: [crates.{package}] needs `{}` to use exactly one crate `{krate}`, and it uses {}", + "rust2-ffi: [crates.{package}] needs `{}` to use crate `{krate}` from {}, and it uses {} of them", self.config.package, + self.crates[i].root.display(), found.len() )); return None; @@ -228,6 +236,16 @@ impl Export { } } +/// The root file another crate was compiled from, canonical, unless rustc remapped it. +fn root_file(tcx: TyCtxt<'_>, cnum: CrateNum) -> Option { + let span = tcx.def_span(cnum.as_def_id()); + let file = tcx.sess.source_map().lookup_source_file(span.lo()); + let rustc_span::FileName::Real(name) = &file.name else { + return None; + }; + std::fs::canonicalize(name.local_path()?).ok() +} + /// Where `span` starts, relative to the crate's directory when inside it. fn at(tcx: TyCtxt<'_>, span: Span) -> String { let sm = tcx.sess.source_map(); diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index 50f8e6934..c281aa294 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -393,3 +393,47 @@ fn diamond() { String::from_utf8_lossy(&output.stderr) ); } + +/// Another export is bound by its package, not its crate name: a dependency whose crate +/// shares the export's name stands in for none. +#[test] +fn lookalike() { + let package = package("lookalike"); + let write = |path: &str, text: &str| { + let path = package.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + }; + // Both libraries are crate `shared`, and only `other` is used. + for name in ["exp", "other"] { + write( + &format!("{name}/Cargo.toml"), + &format!( + "[package]\nname = \"lookalike-{name}\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n[lib]\nname = \"shared\"\n" + ), + ); + write( + &format!("{name}/src/lib.rs"), + "pub struct Size {\n\tpub width: u32,\n}\n", + ); + } + write( + "Cargo.toml", + "[package]\nname = \"lookalike\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n\ + [dependencies]\nexp = { package = \"lookalike-exp\", path = \"exp\" }\n\ + other = { package = \"lookalike-other\", path = \"other\" }\n\n\ + [workspace]\nexclude = [\"exp\", \"other\"]\n", + ); + write( + "src/lib.rs", + "pub fn width(size: other::Size) -> u32 {\n\tsize.width\n}\n", + ); + write("ffi.toml", "package = \"lookalike\"\n\n[crates.lookalike-exp]\n"); + let output = census(&package.join("ffi.toml"), &[]).output().unwrap(); + assert!(!output.status.success(), "a lookalike stood in for the export"); + let stderr = String::from_utf8_lossy(&output.stderr); + assert!( + stderr.contains("[crates.lookalike-exp] needs `lookalike` to use crate `shared`"), + "{stderr}" + ); +} From 3fcec97edb63679275d6d857d6a36d4e6ef9c4fa Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:02:04 -0700 Subject: [PATCH 12/16] fix(ffi): bind each export through the configured package's dependency on it A root file is no identity: two packages may share one, and remapping hides it. Each export is now the crate rustc loaded from the file cargo passed as `--extern` for the configured package's dependency on it, and its items' paths start with that dependency's name, which the glue depends on it by. Two exports of one crate name now export. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115AT2ue6x9cT5e5J9SZtCn --- ffi/README.md | 4 ++-- ffi/src/cli.rs | 28 +++++++++++++++++------- ffi/src/collect.rs | 13 ++++++----- ffi/src/config.rs | 18 +++++----------- ffi/src/driver.rs | 50 ++++++++++++++++++++++++++----------------- ffi/src/glue.rs | 6 ++++-- ffi/tests/fixtures.rs | 45 +++++++++++++++++++++++++++++++++----- 7 files changed, 109 insertions(+), 55 deletions(-) diff --git a/ffi/README.md b/ffi/README.md index 8d399e324..f53fc72bd 100644 --- a/ffi/README.md +++ b/ffi/README.md @@ -13,8 +13,8 @@ the front end it shares with [rust2-ts](../ts/README.md) in [`driver/`](../drive 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. It walks each other package the config lists the same way, from the metadata of the -crate the library builds with, 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 +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/.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 /`; such a feature on a diff --git a/ffi/src/cli.rs b/ffi/src/cli.rs index 1e78516ee..4e39848a1 100644 --- a/ffi/src/cli.rs +++ b/ffi/src/cli.rs @@ -120,15 +120,26 @@ fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result Some(lib.name.clone()), + false => (top.dependencies.iter()) + .filter(|d| d.kind.is_none() && d.name == package.name && d.path.as_deref() == Some(dir)) + .map(|d| d.rename.clone().unwrap_or_else(|| lib.name.clone())) + .min(), + }; + let Some(krate) = krate else { + return Err(format!( + "{name} is not a dependency of {}, which names each export", + config.package + )); + }; let package = config::Package { name: name.clone(), - krate: lib.name.replace('-', "_"), - dir: package - .manifest_path - .parent() - .expect("a manifest path has a parent") - .into(), - root: lib.src_path.clone(), + krate: krate.replace('-', "_"), + dir: dir.into(), features: features.get(name.as_str()).cloned().unwrap_or_default(), }; lines.push_str(&package.line()); @@ -254,13 +265,14 @@ struct Dependency { rename: Option, /// `dev` or `build`, or none for a normal dependency. kind: Option, + /// The package's directory, for a path dependency. + path: Option, } #[derive(serde::Deserialize)] struct Target { name: String, kind: Vec, - src_path: PathBuf, } #[cfg(test)] diff --git a/ffi/src/collect.rs b/ffi/src/collect.rs index 7898ffc02..8f48bb785 100644 --- a/ffi/src/collect.rs +++ b/ffi/src/collect.rs @@ -59,6 +59,8 @@ pub struct Collected { /// An exported crate. pub struct Crate<'a> { pub krate: CrateNum, + /// The name Rust code calls it by, the first segment of its items' paths in the IR. + pub name: &'a str, pub package: &'a str, /// Paths from its root the config leaves out. pub exclude: &'a [String], @@ -67,7 +69,8 @@ pub struct Crate<'a> { /// What collection found in one exported crate, by paths from its root. pub struct Report { pub package: String, - /// Its crate's name, the first segment of its items' paths in the IR. + /// The name Rust code calls its crate by, the first segment of its items' paths in + /// the IR. pub krate: String, pub refusals: Vec, /// Records and enums exported as objects, since a field does not map: the first. @@ -153,7 +156,7 @@ pub fn collect<'tcx>(tcx: TyCtxt<'tcx>, crates: &[Crate<'_>]) -> Collected { .iter() .map(|c| Report { package: c.package.to_string(), - krate: tcx.crate_name(c.krate).to_string(), + krate: c.name.to_string(), refusals: Vec::new(), opaque: Vec::new(), twins: Vec::new(), @@ -224,13 +227,13 @@ impl<'tcx> Collector<'tcx> { } /// Every `pub` item reachable from each exported crate's root, breadth first so each - /// keeps its shortest path, which starts with the crate's name. + /// keeps its shortest path, which starts with the name Rust code calls its crate by. fn walk(&mut self) { let tcx = self.tcx; - for krate in self.crates.clone() { + for (i, krate) in self.crates.clone().into_iter().enumerate() { // Per export: two may re-export one item, and each refuses or excludes it. let mut seen = rustc_data_structures::fx::FxHashSet::default(); - let mut queue = VecDeque::from([(krate.as_def_id(), vec![tcx.crate_name(krate).to_string()])]); + let mut queue = VecDeque::from([(krate.as_def_id(), vec![self.reports[i].krate.clone()])]); while let Some((module, path)) = queue.pop_front() { let children = match module.as_local() { Some(local) => tcx.module_children_local(local), diff --git a/ffi/src/config.rs b/ffi/src/config.rs index 246b7ebfc..156ebcb30 100644 --- a/ffi/src/config.rs +++ b/ffi/src/config.rs @@ -63,12 +63,10 @@ impl Config { /// An export's package, as the CLI resolves it for the wrapper. pub struct Package { pub name: String, - /// Its library's crate name. + /// The name Rust code calls its library by: the configured package's crate name, and + /// another's name as a dependency of it, which binds it to the crate rustc loaded. pub krate: String, pub dir: PathBuf, - /// Its library's root file, which binds it to the crate rustc loaded: another - /// package's crate may share its name. - pub root: PathBuf, /// The features the generated crate enables in it, beyond what the configured /// package's enable. pub features: Vec, @@ -77,24 +75,18 @@ pub struct Package { impl Package { /// One line of [`env::CRATES`]. pub fn line(&self) -> String { - let (dir, root) = (self.dir.display(), self.root.display()); - format!( - "{}\t{}\t{dir}\t{root}\t{}\n", - self.name, - self.krate, - self.features.join(",") - ) + let dir = self.dir.display(); + format!("{}\t{}\t{dir}\t{}\n", self.name, self.krate, self.features.join(",")) } /// The packages [`env::CRATES`] lists, a line each. pub fn parse(lines: &str) -> Result, String> { (lines.lines()) .map(|line| match line.split('\t').collect::>()[..] { - [name, krate, dir, root, features] => Ok(Self { + [name, krate, dir, features] => Ok(Self { name: name.to_string(), krate: krate.to_string(), dir: PathBuf::from(dir), - root: PathBuf::from(root), features: features .split(',') .filter(|f| !f.is_empty()) diff --git a/ffi/src/driver.rs b/ffi/src/driver.rs index 511bc4f75..c1af7b429 100644 --- a/ffi/src/driver.rs +++ b/ffi/src/driver.rs @@ -178,35 +178,38 @@ impl Export { for ( i, config::Package { - name: package, - krate, - root, - .. + name: package, krate, .. }, ) in self.crates.iter().enumerate() { - // Bound by its root file: another package's crate may share its name. - let root = std::fs::canonicalize(root).ok(); let found: Vec = match i { 0 => vec![LOCAL_CRATE], - _ => (tcx.crates(()).iter().copied()) - .filter(|&cnum| { - tcx.crate_name(cnum).as_str() == krate && root.is_some() && root_file(tcx, cnum) == root - }) - .collect(), + _ => { + // The artifact cargo passed for the dependency, which is the package's + // own: another package's crate may share its name and source. + let files = extern_files(tcx, krate); + (tcx.crates(()).iter().copied()) + .filter(|&cnum| { + let source = tcx.used_crate_source(cnum); + let paths = [&source.rlib, &source.rmeta, &source.dylib]; + (paths.into_iter().flatten()) + .any(|path| canonical(path).is_some_and(|p| files.contains(&p))) + }) + .collect() + } }; // Rust loads only the crates a crate's code names. let [cnum] = found[..] else { tcx.dcx().err(format!( - "rust2-ffi: [crates.{package}] needs `{}` to use crate `{krate}` from {}, and it uses {} of them", + "rust2-ffi: [crates.{package}] needs `{}` to use its dependency `{krate}`, and it uses {} crates of it", self.config.package, - self.crates[i].root.display(), found.len() )); return None; }; crates.push(collect::Crate { krate: cnum, + name: krate, package, // A census counts the whole API, whatever the config leaves out. exclude: match self.output { @@ -236,14 +239,21 @@ impl Export { } } -/// The root file another crate was compiled from, canonical, unless rustc remapped it. -fn root_file(tcx: TyCtxt<'_>, cnum: CrateNum) -> Option { - let span = tcx.def_span(cnum.as_def_id()); - let file = tcx.sess.source_map().lookup_source_file(span.lo()); - let rustc_span::FileName::Real(name) = &file.name else { - return None; +/// The files `--extern =` passes, canonical. +fn extern_files(tcx: TyCtxt<'_>, name: &str) -> Vec { + let Some(entry) = tcx.sess.opts.externs.get(name) else { + return Vec::new(); }; - std::fs::canonicalize(name.local_path()?).ok() + match &entry.location { + rustc_session::config::ExternLocation::ExactPaths(paths) => { + paths.iter().filter_map(|p| canonical(p.canonicalized())).collect() + } + rustc_session::config::ExternLocation::FoundInLibrarySearchDirectories => Vec::new(), + } +} + +fn canonical(path: &Path) -> Option { + std::fs::canonicalize(path).ok() } /// Where `span` starts, relative to the crate's directory when inside it. diff --git a/ffi/src/glue.rs b/ffi/src/glue.rs index 3e0ff5ce7..0e52d9de3 100644 --- a/ffi/src/glue.rs +++ b/ffi/src/glue.rs @@ -52,13 +52,15 @@ impl Crate<'_> { /// Write the crate into `out`: `Cargo.toml`, `src/`, and `include/.h`. pub fn write(&self, out: &Path) -> Result<(), String> { - // Cargo unifies in what the configured package enables in each other export. + // Each by the name its items' paths start with. Cargo unifies in what the configured + // package enables in each other export. let dependencies: String = (self.exports.iter()) .map(|export| { let mut features: Vec = export.features.iter().map(|f| format!("{f:?}")).collect(); features.sort(); format!( - "{} = {{ path = {:?}, default-features = false, features = [{}] }}\n", + "{} = {{ package = {:?}, path = {:?}, default-features = false, features = [{}] }}\n", + export.krate, export.name, export.dir.display().to_string(), features.join(", ") diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index c281aa294..28971dd16 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -394,8 +394,10 @@ fn diamond() { ); } -/// Another export is bound by its package, not its crate name: a dependency whose crate -/// shares the export's name stands in for none. +/// Another export is bound by the configured package's dependency on it, not its crate +/// name or source: a used dependency whose crate shares an unused export's name stands +/// in for none, and two exports of one crate name export by their dependency names, +/// with their sources remapped. #[test] fn lookalike() { let package = package("lookalike"); @@ -404,7 +406,7 @@ fn lookalike() { std::fs::create_dir_all(path.parent().unwrap()).unwrap(); std::fs::write(path, text).unwrap(); }; - // Both libraries are crate `shared`, and only `other` is used. + // Both libraries are crate `shared`. for name in ["exp", "other"] { write( &format!("{name}/Cargo.toml"), @@ -414,7 +416,7 @@ fn lookalike() { ); write( &format!("{name}/src/lib.rs"), - "pub struct Size {\n\tpub width: u32,\n}\n", + "pub struct Size {\n\tpub width: u32,\n}\n\nimpl Size {\n\tpub fn new(width: u32) -> Self {\n\t\tSelf { width }\n\t}\n}\n", ); } write( @@ -433,7 +435,40 @@ fn lookalike() { assert!(!output.status.success(), "a lookalike stood in for the export"); let stderr = String::from_utf8_lossy(&output.stderr); assert!( - stderr.contains("[crates.lookalike-exp] needs `lookalike` to use crate `shared`"), + stderr.contains( + "[crates.lookalike-exp] needs `lookalike` to use its dependency `exp`, and it uses 0 crates of it" + ), "{stderr}" ); + + write( + "src/lib.rs", + "pub fn total(a: exp::Size, b: other::Size) -> u32 {\n\ta.width + b.width\n}\n", + ); + write( + "ffi.toml", + "package = \"lookalike\"\n\n[crates.lookalike-exp]\n\n\ + [crates.lookalike-exp.rename]\n\"Size\" = \"exp_size\"\n\n[crates.lookalike-other]\n", + ); + let out = package.join("out"); + let remap = format!("--remap-path-prefix={}=/src", package.display()); + let output = Command::new(env!("CARGO_BIN_EXE_rust2-ffi")) + .arg("--config") + .arg(package.join("ffi.toml")) + .arg("--out") + .arg(&out) + .env("CARGO_TARGET_DIR", package.join("build")) + .env("RUSTFLAGS", remap) + .output() + .unwrap(); + assert!( + output.status.success(), + "the export failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + run(cargo() + .args(["build", "--quiet", "--manifest-path"]) + .arg(out.join("Cargo.toml")) + .arg("--target-dir") + .arg(package.join("glue"))); } From 1d71d86b8096b2479bbe5fb4256c6da2e88fa6a2 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:13:22 -0700 Subject: [PATCH 13/16] fix(ffi): name exports by resolved dependency, and apart from the glue's own A patched-in export's declaration names no path, so the dependency name now comes from metadata's resolve graph on the host, by package. The glue depends on each export as `_export`, so an export the configured package calls `bytes` or `std` no longer collides with the glue's own. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115AT2ue6x9cT5e5J9SZtCn --- ffi/src/cli.rs | 49 ++++++++++++++++++++++++++++++++++++------- ffi/src/glue.rs | 15 +++++++++---- ffi/tests/fixtures.rs | 45 ++++++++++++++++++++++++++++++++++----- 3 files changed, 92 insertions(+), 17 deletions(-) diff --git a/ffi/src/cli.rs b/ffi/src/cli.rs index 4e39848a1..a9a02c888 100644 --- a/ffi/src/cli.rs +++ b/ffi/src/cli.rs @@ -60,7 +60,13 @@ fn try_run(args: &[String]) -> Result { /// first. fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result { let picks = Picks::parse(cargo)?; - let output = run_cargo(manifest, &["metadata", "--format-version", "1"], &picks)?; + // On the host, so the resolved dependencies are the ones the check passes rustc. + let host = rustc_session::config::host_tuple(); + let output = run_cargo( + manifest, + &["metadata", "--format-version", "1", "--filter-platform", host], + &picks, + )?; let metadata: Metadata = serde_json::from_slice(&output).map_err(|err| format!("reading cargo metadata: {err}"))?; let canonical = std::fs::canonicalize(manifest).map_err(|err| format!("resolving {}: {err}", manifest.display()))?; @@ -123,11 +129,12 @@ fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result Some(lib.name.clone()), - false => (top.dependencies.iter()) - .filter(|d| d.kind.is_none() && d.name == package.name && d.path.as_deref() == Some(dir)) - .map(|d| d.rename.clone().unwrap_or_else(|| lib.name.clone())) + false => (node.into_iter().flat_map(|n| &n.deps)) + .filter(|d| d.pkg == package.id && d.dep_kinds.iter().any(|k| k.kind.is_none())) + .map(|d| d.name.clone()) .min(), }; let Some(krate) = krate else { @@ -241,14 +248,42 @@ impl<'a> Picks<'a> { } } -/// What `cargo metadata` says of the packages. +/// What `cargo metadata` says of the packages, and how they depend on each other. #[derive(serde::Deserialize)] struct Metadata { packages: Vec, + resolve: Resolve, +} + +#[derive(serde::Deserialize)] +struct Resolve { + nodes: Vec, +} + +#[derive(serde::Deserialize)] +struct Node { + id: String, + deps: Vec, +} + +/// A resolved dependency. +#[derive(serde::Deserialize)] +struct Dep { + /// The name the dependent calls its library by. + name: String, + pkg: String, + dep_kinds: Vec, +} + +#[derive(serde::Deserialize)] +struct DepKind { + /// `dev` or `build`, or none for a normal dependency. + kind: Option, } #[derive(serde::Deserialize)] struct Package { + id: String, name: String, version: String, source: Option, @@ -265,8 +300,6 @@ struct Dependency { rename: Option, /// `dev` or `build`, or none for a normal dependency. kind: Option, - /// The package's directory, for a path dependency. - path: Option, } #[derive(serde::Deserialize)] diff --git a/ffi/src/glue.rs b/ffi/src/glue.rs index 0e52d9de3..f774237fd 100644 --- a/ffi/src/glue.rs +++ b/ffi/src/glue.rs @@ -15,6 +15,13 @@ use crate::ir::{Access, Function, Mutability, Receiver, Traits, Type}; const RUNTIME: &str = include_str!("../runtime/rt.rs"); +/// The name the crate depends on an export by, the one its items' paths start with +/// suffixed: the configured package's names for them may be `bytes` or `std`, which the +/// crate names too. +fn dependency(krate: &str) -> String { + format!("{krate}_export") +} + /// The Rust spelling of a type a callback's method takes or returns. fn rust(ty: &Type) -> String { match ty { @@ -52,15 +59,14 @@ impl Crate<'_> { /// Write the crate into `out`: `Cargo.toml`, `src/`, and `include/.h`. pub fn write(&self, out: &Path) -> Result<(), String> { - // Each by the name its items' paths start with. Cargo unifies in what the configured - // package enables in each other export. + // Cargo unifies in what the configured package enables in each other export. let dependencies: String = (self.exports.iter()) .map(|export| { let mut features: Vec = export.features.iter().map(|f| format!("{f:?}")).collect(); features.sort(); format!( "{} = {{ package = {:?}, path = {:?}, default-features = false, features = [{}] }}\n", - export.krate, + dependency(&export.krate), export.name, export.dir.display().to_string(), features.join(", ") @@ -115,8 +121,9 @@ impl Gen<'_> { self.out.push('\n'); } + /// A Rust path, through the crate's dependency on its export. fn path(&self, path: &[String]) -> String { - path.join("::") + format!("::{}", dependency(&path[0])) + &path[1..].iter().map(|s| format!("::{s}")).collect::() } /// An object's Rust type: its lifetimes `'static`, as the handle holds it. diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index 28971dd16..771dab32b 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -396,8 +396,8 @@ fn diamond() { /// Another export is bound by the configured package's dependency on it, not its crate /// name or source: a used dependency whose crate shares an unused export's name stands -/// in for none, and two exports of one crate name export by their dependency names, -/// with their sources remapped. +/// in for none, and two exports of one crate name export by their dependency names, one +/// of them `bytes`, which the glue names too, with their sources remapped. #[test] fn lookalike() { let package = package("lookalike"); @@ -423,12 +423,12 @@ fn lookalike() { "Cargo.toml", "[package]\nname = \"lookalike\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n\ [dependencies]\nexp = { package = \"lookalike-exp\", path = \"exp\" }\n\ - other = { package = \"lookalike-other\", path = \"other\" }\n\n\ + bytes = { package = \"lookalike-other\", path = \"other\" }\n\n\ [workspace]\nexclude = [\"exp\", \"other\"]\n", ); write( "src/lib.rs", - "pub fn width(size: other::Size) -> u32 {\n\tsize.width\n}\n", + "pub fn width(size: bytes::Size) -> u32 {\n\tsize.width\n}\n", ); write("ffi.toml", "package = \"lookalike\"\n\n[crates.lookalike-exp]\n"); let output = census(&package.join("ffi.toml"), &[]).output().unwrap(); @@ -443,7 +443,7 @@ fn lookalike() { write( "src/lib.rs", - "pub fn total(a: exp::Size, b: other::Size) -> u32 {\n\ta.width + b.width\n}\n", + "pub fn total(a: exp::Size, b: bytes::Size) -> u32 {\n\ta.width + b.width\n}\n", ); write( "ffi.toml", @@ -472,3 +472,38 @@ fn lookalike() { .arg("--target-dir") .arg(package.join("glue"))); } + +/// Another export patched in for a registry dependency is bound by its resolved package, +/// though the dependency names no path. +#[test] +fn patched() { + let package = package("patched"); + let write = |path: &str, text: &str| { + let path = package.join(path); + std::fs::create_dir_all(path.parent().unwrap()).unwrap(); + std::fs::write(path, text).unwrap(); + }; + write( + "dep/Cargo.toml", + "[package]\nname = \"rust2-ffi-patched-dep\"\nversion = \"1.0.0\"\nedition = \"2024\"\n", + ); + write("dep/src/lib.rs", "pub struct Size {\n\tpub width: u32,\n}\n"); + write( + "Cargo.toml", + "[package]\nname = \"patched\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n\ + [dependencies]\nrust2-ffi-patched-dep = \"1\"\n\n\ + [patch.crates-io]\nrust2-ffi-patched-dep = { path = \"dep\" }\n\n\ + [workspace]\nexclude = [\"dep\"]\n", + ); + write( + "src/lib.rs", + "pub fn width(size: rust2_ffi_patched_dep::Size) -> u32 {\n\tsize.width\n}\n", + ); + write("ffi.toml", "package = \"patched\"\n\n[crates.rust2-ffi-patched-dep]\n"); + let output = census(&package.join("ffi.toml"), &["--offline"]).output().unwrap(); + assert!( + output.status.success(), + "the census failed:\n{}", + String::from_utf8_lossy(&output.stderr) + ); +} From 2dd1961754b56cefe08002edbc2ef02ab9729547 Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:23:20 -0700 Subject: [PATCH 14/16] fix(ffi): root each export's paths at its package name The names the configured package calls its exports by may coincide with its own crate name, so they now only bind each export to its artifact. Its items' paths, and the glue's dependency on it, start with its package name in snake case, which is unique among the exports. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115AT2ue6x9cT5e5J9SZtCn --- ffi/fixtures/reject/expected.txt | 4 ++-- ffi/src/cli.rs | 7 +++++++ ffi/src/collect.rs | 11 +++++------ ffi/src/config.rs | 11 +++++++++-- ffi/src/driver.rs | 2 +- ffi/src/glue.rs | 7 +++---- ffi/src/ir.rs | 4 ++-- ffi/tests/fixtures.rs | 13 +++++++------ 8 files changed, 36 insertions(+), 23 deletions(-) diff --git a/ffi/fixtures/reject/expected.txt b/ffi/fixtures/reject/expected.txt index d1df3f962..7766a0a42 100644 --- a/ffi/fixtures/reject/expected.txt +++ b/ffi/fixtures/reject/expected.txt @@ -1,7 +1,7 @@ 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::Size` and struct `reject_fixture::Size` are both `reject_size_free` in C +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::Size` and struct `reject_fixture::Size` are both `reject_size` in C +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 diff --git a/ffi/src/cli.rs b/ffi/src/cli.rs index a9a02c888..da93263c2 100644 --- a/ffi/src/cli.rs +++ b/ffi/src/cli.rs @@ -149,6 +149,13 @@ fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result { pub krate: CrateNum, - /// The name Rust code calls it by, the first segment of its items' paths in the IR. - pub name: &'a str, + /// The first segment of its items' paths in the IR. + pub root: String, pub package: &'a str, /// Paths from its root the config leaves out. pub exclude: &'a [String], @@ -69,8 +69,7 @@ pub struct Crate<'a> { /// What collection found in one exported crate, by paths from its root. pub struct Report { pub package: String, - /// The name Rust code calls its crate by, the first segment of its items' paths in - /// the IR. + /// Its crate's root, the first segment of its items' paths in the IR. pub krate: String, pub refusals: Vec, /// Records and enums exported as objects, since a field does not map: the first. @@ -156,7 +155,7 @@ pub fn collect<'tcx>(tcx: TyCtxt<'tcx>, crates: &[Crate<'_>]) -> Collected { .iter() .map(|c| Report { package: c.package.to_string(), - krate: c.name.to_string(), + krate: c.root.clone(), refusals: Vec::new(), opaque: Vec::new(), twins: Vec::new(), @@ -227,7 +226,7 @@ impl<'tcx> Collector<'tcx> { } /// Every `pub` item reachable from each exported crate's root, breadth first so each - /// keeps its shortest path, which starts with the name Rust code calls its crate by. + /// keeps its shortest path, which starts with its crate's root. fn walk(&mut self) { let tcx = self.tcx; for (i, krate) in self.crates.clone().into_iter().enumerate() { diff --git a/ffi/src/config.rs b/ffi/src/config.rs index 156ebcb30..9600f57bf 100644 --- a/ffi/src/config.rs +++ b/ffi/src/config.rs @@ -63,8 +63,8 @@ impl Config { /// An export's package, as the CLI resolves it for the wrapper. pub struct Package { pub name: String, - /// The name Rust code calls its library by: the configured package's crate name, and - /// another's name as a dependency of it, which binds it to the crate rustc loaded. + /// The name the configured package calls its library by, which binds another export + /// to the crate rustc loaded: the configured package's own crate name. pub krate: String, pub dir: PathBuf, /// The features the generated crate enables in it, beyond what the configured @@ -73,6 +73,13 @@ pub struct Package { } impl Package { + /// The first segment of its items' paths in the IR, which the glue depends on it by: + /// its name in snake case, unique among the exports, where the names the configured + /// package calls them by may coincide with each other and its own. + pub fn root(&self) -> String { + self.name.replace('-', "_") + } + /// One line of [`env::CRATES`]. pub fn line(&self) -> String { let dir = self.dir.display(); diff --git a/ffi/src/driver.rs b/ffi/src/driver.rs index c1af7b429..de7556977 100644 --- a/ffi/src/driver.rs +++ b/ffi/src/driver.rs @@ -209,7 +209,7 @@ impl Export { }; crates.push(collect::Crate { krate: cnum, - name: krate, + root: self.crates[i].root(), package, // A census counts the whole API, whatever the config leaves out. exclude: match self.output { diff --git a/ffi/src/glue.rs b/ffi/src/glue.rs index f774237fd..34a4ca4c2 100644 --- a/ffi/src/glue.rs +++ b/ffi/src/glue.rs @@ -15,9 +15,8 @@ use crate::ir::{Access, Function, Mutability, Receiver, Traits, Type}; const RUNTIME: &str = include_str!("../runtime/rt.rs"); -/// The name the crate depends on an export by, the one its items' paths start with -/// suffixed: the configured package's names for them may be `bytes` or `std`, which the -/// crate names too. +/// The name the crate depends on an export by, its root suffixed: an export may be +/// named `bytes` or `std`, which the crate names too. fn dependency(krate: &str) -> String { format!("{krate}_export") } @@ -66,7 +65,7 @@ impl Crate<'_> { features.sort(); format!( "{} = {{ package = {:?}, path = {:?}, default-features = false, features = [{}] }}\n", - dependency(&export.krate), + dependency(&export.root()), export.name, export.dir.display().to_string(), features.join(", ") diff --git a/ffi/src/ir.rs b/ffi/src/ir.rs index 3917996bc..69455670c 100644 --- a/ffi/src/ir.rs +++ b/ffi/src/ir.rs @@ -16,8 +16,8 @@ pub struct Api { pub constants: Vec, } -/// A Rust path: its crate's name, then the path from that crate's root, such as -/// `["moq_net", "track", "Producer", "append_group"]`. +/// A Rust path: its export's package name in snake case, unique among the exports, then +/// the path from that crate's root, such as `["moq_net", "track", "Producer", "append_group"]`. pub type Path = Vec; /// A struct whose fields are all `pub`: a value, copied across. diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index 771dab32b..38ebd1406 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -396,8 +396,9 @@ fn diamond() { /// Another export is bound by the configured package's dependency on it, not its crate /// name or source: a used dependency whose crate shares an unused export's name stands -/// in for none, and two exports of one crate name export by their dependency names, one -/// of them `bytes`, which the glue names too, with their sources remapped. +/// in for none, and two exports of one crate name export, by names the configured package +/// calls them that are its own crate name and `bytes`, which the glue names too, with +/// their sources remapped. #[test] fn lookalike() { let package = package("lookalike"); @@ -421,8 +422,8 @@ fn lookalike() { } write( "Cargo.toml", - "[package]\nname = \"lookalike\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n\ - [dependencies]\nexp = { package = \"lookalike-exp\", path = \"exp\" }\n\ + "[package]\nname = \"lookalike\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n[lib]\nname = \"shared\"\n\n\ + [dependencies]\nshared = { package = \"lookalike-exp\", path = \"exp\" }\n\ bytes = { package = \"lookalike-other\", path = \"other\" }\n\n\ [workspace]\nexclude = [\"exp\", \"other\"]\n", ); @@ -436,14 +437,14 @@ fn lookalike() { let stderr = String::from_utf8_lossy(&output.stderr); assert!( stderr.contains( - "[crates.lookalike-exp] needs `lookalike` to use its dependency `exp`, and it uses 0 crates of it" + "[crates.lookalike-exp] needs `lookalike` to use its dependency `shared`, and it uses 0 crates of it" ), "{stderr}" ); write( "src/lib.rs", - "pub fn total(a: exp::Size, b: bytes::Size) -> u32 {\n\ta.width + b.width\n}\n", + "pub fn total(a: shared::Size, b: bytes::Size) -> u32 {\n\ta.width + b.width\n}\n", ); write( "ffi.toml", From a30c93ddc8b53096aad06279ff80ef07e283a40a Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:34:02 -0700 Subject: [PATCH 15/16] fix(ffi): patch the glue's workspace as the configured one is The glue crate is its own workspace, so the configured workspace's `[patch]` no longer applied: a patched-in export resolved from the registry, and moq's patched `kio` was another copy. The glue's manifest now carries the workspace root's `[patch]` and `[replace]` tables, each path absolute. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115AT2ue6x9cT5e5J9SZtCn --- ffi/src/cli.rs | 42 ++++++++++++++++++++++++++++++++++++++---- ffi/src/config.rs | 3 +++ ffi/src/driver.rs | 11 ++++++++++- ffi/src/glue.rs | 7 ++++++- ffi/tests/fixtures.rs | 21 ++++++++++++++++++--- 5 files changed, 75 insertions(+), 9 deletions(-) diff --git a/ffi/src/cli.rs b/ffi/src/cli.rs index da93263c2..fb3772e49 100644 --- a/ffi/src/cli.rs +++ b/ffi/src/cli.rs @@ -34,7 +34,7 @@ fn try_run(args: &[String]) -> Result { return Ok(ExitCode::SUCCESS); }; let config = Config::load(&args.config)?; - let crates = crates(&args.manifest, &config, &args.cargo)?; + let (crates, patch) = crates(&args.manifest, &config, &args.cargo)?; let output = match &args.output { Output::Dir(out) => (config::env::OUT, out.as_os_str()), Output::Census(census) => (config::env::CENSUS, census.as_os_str()), @@ -50,6 +50,7 @@ fn try_run(args: &[String]) -> Result { env: &[ (config::env::CONFIG, args.config.as_os_str()), (config::env::CRATES, crates.as_ref()), + (config::env::PATCH, patch.as_ref()), output, ], } @@ -57,8 +58,8 @@ fn try_run(args: &[String]) -> Result { } /// Each export's package, as [`config::env::CRATES`] lists them: the configured package -/// first. -fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result { +/// first. Then its workspace's patches, as [`config::env::PATCH`] has them. +fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result<(String, String), String> { let picks = Picks::parse(cargo)?; // On the host, so the resolved dependencies are the ones the check passes rustc. let host = rustc_session::config::host_tuple(); @@ -158,7 +159,39 @@ fn crates(manifest: &Path, config: &Config, cargo: &[String]) -> Result Result { + let manifest = root.join("Cargo.toml"); + let text = std::fs::read_to_string(&manifest).map_err(|err| format!("reading {}: {err}", manifest.display()))?; + let table: toml::Table = text + .parse() + .map_err(|err| format!("parsing {}: {err}", manifest.display()))?; + let mut patches = toml::Table::new(); + for key in ["patch", "replace"] { + if let Some(value) = table.get(key) { + let mut value = value.clone(); + absolute(&mut value, root); + patches.insert(key.to_string(), value); + } + } + toml::to_string(&patches).map_err(|err| format!("writing the patches of {}: {err}", manifest.display())) +} + +/// Make each `path` in a patch absolute, from the directory its manifest is in. +fn absolute(value: &mut toml::Value, root: &Path) { + let toml::Value::Table(table) = value else { + return; + }; + for (key, value) in table.iter_mut() { + match (key.as_str(), value) { + ("path", toml::Value::String(path)) => *path = root.join(&*path).display().to_string(), + (_, value) => absolute(value, root), + } + } } /// What a cargo subcommand prints for the package, run as the check resolves: from the @@ -260,6 +293,7 @@ impl<'a> Picks<'a> { struct Metadata { packages: Vec, resolve: Resolve, + workspace_root: PathBuf, } #[derive(serde::Deserialize)] diff --git a/ffi/src/config.rs b/ffi/src/config.rs index 9600f57bf..e0ca55008 100644 --- a/ffi/src/config.rs +++ b/ffi/src/config.rs @@ -116,4 +116,7 @@ pub mod env { pub const CENSUS: &str = "RUST2_FFI_CENSUS"; /// Each export's [`Package`](super::Package), a line each. pub const CRATES: &str = "RUST2_FFI_CRATES"; + /// The configured workspace's `[patch]` and `[replace]` tables, as TOML with each + /// path absolute. + pub const PATCH: &str = "RUST2_FFI_PATCH"; } diff --git a/ffi/src/driver.rs b/ffi/src/driver.rs index de7556977..fb7a6982a 100644 --- a/ffi/src/driver.rs +++ b/ffi/src/driver.rs @@ -29,6 +29,8 @@ struct Export { /// Each export's package: the configured package first, with the features rustc /// compiles it with. crates: Vec, + /// The configured workspace's patches, for the glue's workspace. + patch: String, } /// What the job writes. @@ -75,7 +77,13 @@ impl Export { .filter_map(|f| f.strip_suffix('"')) .map(str::to_string) .collect(); - Ok(Some(Self { config, output, crates })) + let patch = std::env::var(config::env::PATCH).map_err(|_| format!("{} is unset", config::env::PATCH))?; + Ok(Some(Self { + config, + output, + crates, + patch, + })) } } @@ -157,6 +165,7 @@ impl Callbacks for Export { let glue = glue::Crate { abi: &abi, exports: &self.crates, + patch: &self.patch, }; glue.write(out) } diff --git a/ffi/src/glue.rs b/ffi/src/glue.rs index 34a4ca4c2..38538152d 100644 --- a/ffi/src/glue.rs +++ b/ffi/src/glue.rs @@ -47,6 +47,9 @@ pub struct Crate<'a> { pub abi: &'a Abi<'a>, /// The exported packages, the configured one first, each a dependency of the crate. pub exports: &'a [Package], + /// The configured workspace's `[patch]` and `[replace]` tables, which the crate's own + /// workspace resolves the exports with. + pub patch: &'a str, } impl Crate<'_> { @@ -88,7 +91,9 @@ impl Crate<'_> { {dependencies}\ bytes = \"1\"\n\ \n\ - [workspace]\n", + [workspace]\n\ + {patch}", + patch = self.patch, package = self.exports[0].name, packages = self.packages(), lib = self.abi.prefix, diff --git a/ffi/tests/fixtures.rs b/ffi/tests/fixtures.rs index 38ebd1406..064e8c365 100644 --- a/ffi/tests/fixtures.rs +++ b/ffi/tests/fixtures.rs @@ -475,7 +475,7 @@ fn lookalike() { } /// Another export patched in for a registry dependency is bound by its resolved package, -/// though the dependency names no path. +/// though the dependency names no path, and the glue builds it patched in too. #[test] fn patched() { let package = package("patched"); @@ -501,10 +501,25 @@ fn patched() { "pub fn width(size: rust2_ffi_patched_dep::Size) -> u32 {\n\tsize.width\n}\n", ); write("ffi.toml", "package = \"patched\"\n\n[crates.rust2-ffi-patched-dep]\n"); - let output = census(&package.join("ffi.toml"), &["--offline"]).output().unwrap(); + let out = package.join("out"); + let output = Command::new(env!("CARGO_BIN_EXE_rust2-ffi")) + .arg("--config") + .arg(package.join("ffi.toml")) + .arg("--out") + .arg(&out) + .args(["--", "--offline"]) + .env("CARGO_TARGET_DIR", package.join("build")) + .output() + .unwrap(); assert!( output.status.success(), - "the census failed:\n{}", + "the export failed:\n{}", String::from_utf8_lossy(&output.stderr) ); + // The glue's workspace patches the export in as the configured one does. + run(cargo() + .args(["build", "--quiet", "--offline", "--manifest-path"]) + .arg(out.join("Cargo.toml")) + .arg("--target-dir") + .arg(package.join("glue"))); } From bf6ea3bdddaa78815032b0bcc7726c544934b97c Mon Sep 17 00:00:00 2001 From: Luke Curley <432854+kixelated@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:44:18 -0700 Subject: [PATCH 16/16] fix(ffi): absolutize only dependency paths in carried patches The recursive rewrite also took a patched package named `path`, whose value is a version requirement, for a path. Only a `[patch.]` or `[replace]` dependency's own `path` is rewritten now. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_0115AT2ue6x9cT5e5J9SZtCn --- ffi/src/cli.rs | 60 +++++++++++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 18 deletions(-) diff --git a/ffi/src/cli.rs b/ffi/src/cli.rs index fb3772e49..d5b53a1d0 100644 --- a/ffi/src/cli.rs +++ b/ffi/src/cli.rs @@ -170,28 +170,37 @@ fn patches(root: &Path) -> Result { let table: toml::Table = text .parse() .map_err(|err| format!("parsing {}: {err}", manifest.display()))?; - let mut patches = toml::Table::new(); - for key in ["patch", "replace"] { - if let Some(value) = table.get(key) { - let mut value = value.clone(); - absolute(&mut value, root); - patches.insert(key.to_string(), value); - } - } - toml::to_string(&patches).map_err(|err| format!("writing the patches of {}: {err}", manifest.display())) + toml::to_string(&overrides(&table, root)) + .map_err(|err| format!("writing the patches of {}: {err}", manifest.display())) } -/// Make each `path` in a patch absolute, from the directory its manifest is in. -fn absolute(value: &mut toml::Value, root: &Path) { - let toml::Value::Table(table) = value else { - return; - }; - for (key, value) in table.iter_mut() { - match (key.as_str(), value) { - ("path", toml::Value::String(path)) => *path = root.join(&*path).display().to_string(), - (_, value) => absolute(value, root), +/// A manifest's `[patch.]` and `[replace]` dependencies, each path made absolute +/// from `root`, the manifest's directory. +fn overrides(manifest: &toml::Table, root: &Path) -> toml::Table { + let mut overrides = manifest.clone(); + overrides.retain(|key, _| key == "patch" || key == "replace"); + let mut deps: Vec<&mut toml::Value> = Vec::new(); + for (key, value) in overrides.iter_mut() { + let Some(table) = value.as_table_mut() else { + continue; + }; + match key.as_str() { + // A table of dependencies for each source. + "patch" => deps.extend( + (table.iter_mut()) + .filter_map(|(_, source)| source.as_table_mut()) + .flat_map(|source| source.iter_mut().map(|(_, dep)| dep)), + ), + _ => deps.extend(table.iter_mut().map(|(_, dep)| dep)), } } + // A dependency's own table: a bare string is a version requirement. + for dep in deps { + if let Some(toml::Value::String(path)) = dep.get_mut("path") { + *path = root.join(path.as_str()).display().to_string(); + } + } + overrides } /// What a cargo subcommand prints for the package, run as the check resolves: from the @@ -353,6 +362,21 @@ struct Target { mod tests { use super::*; + #[test] + fn absolutes_override_paths() { + let manifest: toml::Table = "[dependencies]\nx = { path = \"x\" }\n\n\ + [patch.'https://example.com/git']\npath = \"1\"\nreal = { path = \"real\" }\n\n\ + [replace]\n\"a:1.0.0\" = { path = \"a\" }\n" + .parse() + .unwrap(); + let expected: toml::Table = + "[patch.'https://example.com/git']\npath = \"1\"\nreal = { path = \"/ws/real\" }\n\n\ + [replace]\n\"a:1.0.0\" = { path = \"/ws/a\" }\n" + .parse() + .unwrap(); + assert_eq!(overrides(&manifest, Path::new("/ws")), expected); + } + #[test] fn reads_what_picks_a_resolution() { for spelling in [