diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 44638c4ef..a7e621078 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -158,7 +158,7 @@ jobs: run: nix develop --command just ffi census - name: Summarize - run: awk '/^## Refused items/ { exit } { print }' ffi/target/census/moq-net.md >> "$GITHUB_STEP_SUMMARY" + run: awk 'FNR == 1 { refused = 0 } /^## Refused items/ { refused = 1 } !refused' ffi/target/census/*.md >> "$GITHUB_STEP_SUMMARY" - name: Upload uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 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..f53fc72bd 100644 --- a/ffi/README.md +++ b/ffi/README.md @@ -5,16 +5,20 @@ attributes in the Rust source. It replaces [the UniFFI fork](../uniffi/README.md starting from C, which every other language's generator will build on. ```bash -rust2-ffi --config ffi.toml (--out dir | --census file.md) [--manifest-path Cargo.toml] [-- cargo args] +rust2-ffi --config ffi.toml (--out dir | --census dir) [--manifest-path Cargo.toml] [-- cargo args] ``` It runs `cargo check` on the package with itself as `RUSTC_WRAPPER`, on the host, through the front end it shares with [rust2-ts](../ts/README.md) in [`driver/`](../driver). Once rustc has checked the configured library, it walks every `pub` item reachable from the crate root and exports it by fixed rules, or refuses it with a compile error at its span. -`--out` writes a crate of `extern "C"` functions over the library, its runtime beside it, -and the header in `include/.h`; `cargo build` on that crate makes a static and a -shared library. +It walks each other package the config lists the same way, from the metadata of the +crate the library uses through its dependency on that package, so one library holds them +all and an export names another's types across crates. `--out` writes a crate of `extern "C"` functions over the +libraries, its runtime beside it, and the header in `include/.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 +dependency that is no export is an error. ```toml # ffi.toml, beside the package's Cargo.toml unless --manifest-path says otherwise. @@ -28,13 +32,19 @@ exclude = ["track::Producer::poll_closed"] # C names, without the prefix, for paths whose names the naming rule makes clash. [rename] "session::Stats" = "session_stats_snapshot" + +# Another package exported into the same library, which the package builds with: its +# own `exclude` and `rename`, by paths from its crate root. +[crates.moq-pattern] +exclude = ["Patterns::iter"] ``` ## Rules -Every `pub` item reachable from the crate root, by its shortest path, is exported or -refused. `#[doc(hidden)]` items are no part of the API and are left out; type aliases -export nothing of their own. +Every `pub` item reachable from an exported crate's root, by its shortest path, is +exported or refused. `#[doc(hidden)]` items are no part of the API and are left out; type +aliases export nothing of their own. A re-export of another export's item names that +export's, and adds nothing; another crate's type is refused. | Rust | C | | --- | --- | @@ -89,17 +99,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 +119,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..31fc01185 100644 --- a/ffi/fixtures/api/ffi.toml +++ b/ffi/fixtures/api/ffi.toml @@ -2,3 +2,7 @@ package = "api-fixture" prefix = "api" # The list's tail is not `Clone`, so its getter has no copy to return. exclude = ["Node::next"] + +# Exported into the same library: `grow` names its types. +[crates.dep-fixture] +exclude = ["HashSet"] 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..5730d145d --- /dev/null +++ b/ffi/fixtures/dep/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "dep-fixture" +description = "Another export, whose types the api fixture names across crates" +version = "0.0.0" +edition = "2024" +license = "MIT OR Apache-2.0" +publish = false + +# Explicitly an rlib, which cargo metadata calls one. +[lib] +name = "dep" +crate-type = ["rlib"] + +[features] +# Doubles each area's size: the tests enable it on this crate directly, which the export +# must build as the check did. +doubled = [] + +[lints] +workspace = true diff --git a/ffi/fixtures/dep/src/lib.rs b/ffi/fixtures/dep/src/lib.rs new file mode 100644 index 000000000..4e52e5e22 --- /dev/null +++ b/ffi/fixtures/dep/src/lib.rs @@ -0,0 +1,39 @@ +//! Another export, exported into the api fixture's library, which names its types. + +/// A size: a record of this export, which another's signatures name. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Size { + pub width: u32, + pub height: u32, +} + +/// An area: an object of this export, which another's signatures take and return. +#[derive(Clone, Debug)] +pub struct Area { + size: Size, +} + +impl Area { + /// An area of `size`, doubled on each side with the `doubled` feature. + pub fn new(size: Size) -> Self { + let scale = if cfg!(feature = "doubled") { 2 } else { 1 }; + let size = Size { + width: size.width * scale, + height: size.height * scale, + }; + Self { size } + } + + pub fn size(&self) -> Size { + self.size + } + + /// Its width times its height. + pub fn pixels(&self) -> u64 { + u64::from(self.size.width) * u64::from(self.size.height) + } +} + +/// Another crate's item that is no export, which each export re-exporting it refuses or +/// excludes on its own. +pub use std::collections::HashSet; 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..7766a0a42 100644 --- a/ffi/fixtures/reject/expected.txt +++ b/ffi/fixtures/reject/expected.txt @@ -1,8 +1,14 @@ +fixtures/dep/src/lib.rs:39:9: rust2-ffi: cannot export use `HashSet`: re-export of another crate's item `std`; exclude it in the config's [crates.dep-fixture] to leave it out +fixtures/dep/src/lib.rs:5:1: rust2-ffi: struct `dep_fixture::Size` and struct `reject_fixture::Size` are both `reject_size_free` in C + fixtures/reject/src/lib.rs:139:1: note: struct `reject_fixture::Size` is here +fixtures/dep/src/lib.rs:5:1: rust2-ffi: struct `dep_fixture::Size` and struct `reject_fixture::Size` are both `reject_size` in C + fixtures/reject/src/lib.rs:139:1: note: struct `reject_fixture::Size` is here fixtures/reject/src/lib.rs:113:2: rust2-ffi: cannot export method `Text::replace`: 'static borrow `&'b str`; exclude it in the config to leave it out fixtures/reject/src/lib.rs:113:17: note: `'b` is `'static`, as a handle outlives every call fixtures/reject/src/lib.rs:118:2: rust2-ffi: cannot export method `Text::later`: returned object that borrows `impl std::future::Future>`; 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 +17,8 @@ fixtures/reject/src/lib.rs:40:1: rust2-ffi: cannot export fn `peek`: reference t fixtures/reject/src/lib.rs:45:1: rust2-ffi: cannot export static `COUNT`: static; exclude it in the config to leave it out fixtures/reject/src/lib.rs:49:1: rust2-ffi: cannot export macro `noop`: macro; exclude it in the config to leave it out fixtures/reject/src/lib.rs:57:1: rust2-ffi: cannot export fn `excluded`: excluded type `Excluded`; exclude it in the config to leave it out -fixtures/reject/src/lib.rs:68:2: rust2-ffi: fn `audio::encoder_finish` and fn `audio_encoder::finish` are both `reject_audio_encoder_finish` in C - fixtures/reject/src/lib.rs:63:2: note: fn `audio_encoder::finish` is here +fixtures/reject/src/lib.rs:68:2: rust2-ffi: fn `reject_fixture::audio::encoder_finish` and fn `reject_fixture::audio_encoder::finish` are both `reject_audio_encoder_finish` in C + fixtures/reject/src/lib.rs:63:2: note: fn `reject_fixture::audio_encoder::finish` is here fixtures/reject/src/lib.rs:6:1: rust2-ffi: cannot export struct `Wrap`: generic type; exclude it in the config to leave it out fixtures/reject/src/lib.rs:72:1: rust2-ffi: cannot export fn `letters`: Range `char`; exclude it in the config to leave it out fixtures/reject/src/lib.rs:81:2: rust2-ffi: cannot export method `Text::new`: 'static borrow `&'a str`; exclude it in the config to leave it out 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/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/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..d5b53a1d0 100644 --- a/ffi/src/cli.rs +++ b/ffi/src/cli.rs @@ -1,20 +1,23 @@ //! The command line: run cargo on the configured package with this binary as rustc. -use std::process::ExitCode; +use std::collections::{BTreeMap, BTreeSet}; +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`, +on the host. ---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 +34,7 @@ fn try_run(args: &[String]) -> Result { return Ok(ExitCode::SUCCESS); }; let config = Config::load(&args.config)?; + 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()), @@ -39,10 +43,373 @@ 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()), output], + env: &[ + (config::env::CONFIG, args.config.as_os_str()), + (config::env::CRATES, crates.as_ref()), + (config::env::PATCH, patch.as_ref()), + output, + ], } .run() } + +/// Each export's package, as [`config::env::CRATES`] lists them: the configured package +/// 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(); + 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()))?; + // 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 == canonical)); + let Some(top) = top else { + return Err(format!( + "{} is not the package of {}", + config.package, + manifest.display() + )); + }; + // What the library builds with on the host, each package as ` 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", + // The check's package, not the workspace's default members. + "--package", + &config.package, + "--edges", + "normal", + "--target", + rustc_session::config::host_tuple(), + "--prefix", + "none", + "--format", + "{p}", + // Plain, whatever cargo's config says, so a repeat's `(*)` reads as one. + "--color", + "never", + ]; + 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 = 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. + 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")); + }; + let dir = package.manifest_path.parent().expect("a manifest path has a parent"); + // Another export by the name the configured package depends on it by, unique among + // its dependencies: crates of two packages may share a name. + let node = metadata.resolve.nodes.iter().find(|n| n.id == top.id); + let krate = match package.id == top.id { + true => Some(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 { + return Err(format!( + "{name} is not a dependency of {}, which names each export", + config.package + )); + }; + let package = config::Package { + name: name.clone(), + krate: krate.replace('-', "_"), + dir: dir.into(), + features: features.get(name.as_str()).cloned().unwrap_or_default(), + }; + if lines + .lines() + .filter_map(|l| l.split('\t').next()) + .any(|n| n.replace('-', "_") == package.root()) + { + return Err(format!("{name} is another export's name in snake case")); + } + lines.push_str(&package.line()); + } + Ok((lines, patches(&metadata.workspace_root)?)) +} + +/// The workspace's `[patch]` and `[replace]` tables, with each path absolute, so the +/// glue's own workspace resolves the exports as the check does. +fn patches(root: &Path) -> 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()))?; + toml::to_string(&overrides(&table, root)) + .map_err(|err| format!("writing the patches of {}: {err}", manifest.display())) +} + +/// 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 +/// package's directory, where cargo reads its config, with the check's picks. +fn run_cargo(manifest: &Path, subcommand: &[&str], picks: &Picks<'_>) -> 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. + named: Vec<&'a str>, + /// The other args that pick a resolution: feature, lockfile, network, and `--config`. + flags: Vec<&'a str>, +} + +impl<'a> Picks<'a> { + fn parse(cargo: &'a [String]) -> Result { + 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" | "--locked" | "--frozen" | "--offline" => { + 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; + } + _ if arg.starts_with("--config=") => { + picks.flags.push(arg); + continue; + } + "--features" | "-F" => args.next(), + _ => (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())); + } + Ok(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 { + packages: Vec, + resolve: Resolve, + workspace_root: PathBuf, +} + +#[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, + 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)] +struct Target { + name: String, + kind: Vec, +} + +#[cfg(test)] +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 [ + &["--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!( + Picks::parse(&cargo).unwrap().args(), + ["--features", "a,dep/b"], + "{spelling:?}" + ); + } + 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).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/src/collect.rs b/ffi/src/collect.rs index baf23d427..25ba6b225 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,25 @@ 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, + /// 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], +} + +/// What collection found in one exported crate, by paths from its root. +pub struct Report { + pub package: String, + /// 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. pub opaque: Vec<(String, Why)>, @@ -62,9 +82,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 +101,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 +118,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 +150,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: c.root.clone(), + 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 +187,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 +195,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 +221,86 @@ 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 its crate's root. 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; - }; - // 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; + 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![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), + None => tcx.module_children(module), }; - 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 +492,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 +702,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 +724,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 +1444,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..e0ca55008 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::path::Path; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; 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. @@ -35,12 +60,63 @@ impl Config { } } +/// An export's package, as the CLI resolves it for the wrapper. +pub struct Package { + pub name: String, + /// 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 + /// package's enable. + pub features: Vec, +} + +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(); + 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. 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`](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 f3e55236a..fb7a6982a 100644 --- a/ffi/src/driver.rs +++ b/ffi/src/driver.rs @@ -1,17 +1,19 @@ //! 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; 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 { @@ -24,15 +26,18 @@ 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: 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. 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), } @@ -60,17 +65,24 @@ 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 patch = std::env::var(config::env::PATCH).map_err(|_| format!("{} is unset", config::env::PATCH))?; Ok(Some(Self { config, output, - features, + crates, + patch, })) } } @@ -80,57 +92,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.name).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 +162,10 @@ 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 glue = glue::Crate { abi: &abi, - header: &header, - package: &self.config.package, - manifest_dir: Path::new(&manifest_dir), - features: &self.features, + exports: &self.crates, + patch: &self.patch, }; glue.write(out) } @@ -166,6 +180,91 @@ 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, + config::Package { + name: package, krate, .. + }, + ) in self.crates.iter().enumerate() + { + let found: Vec = match i { + 0 => vec![LOCAL_CRATE], + _ => { + // 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 its dependency `{krate}`, and it uses {} crates of it", + self.config.package, + found.len() + )); + return None; + }; + crates.push(collect::Crate { + krate: cnum, + root: self.crates[i].root(), + 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"), + } + } +} + +/// 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(); + }; + 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. 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..38538152d 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 @@ -10,10 +10,17 @@ use std::fmt::Write; 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"); +/// 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") +} + /// The Rust spelling of a type a callback's method takes or returns. fn rust(ty: &Type) -> String { match ty { @@ -38,19 +45,38 @@ 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 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<'_> { + /// The exported packages, comma-separated. + fn packages(&self) -> String { + 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!( + "{} = {{ package = {:?}, path = {:?}, default-features = false, features = [{}] }}\n", + dependency(&export.root()), + export.name, + export.dir.display().to_string(), + features.join(", ") + ) + }) + .collect(); 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\ @@ -62,31 +88,34 @@ impl Crate<'_> { crate-type = [\"staticlib\", \"cdylib\"]\n\ \n\ [dependencies]\n\ - {package} = {{ path = {path:?}, default-features = false, features = [{features}] }}\n\ + {dependencies}\ bytes = \"1\"\n\ \n\ - [workspace]\n", - package = self.package, + [workspace]\n\ + {patch}", + patch = self.patch, + package = self.exports[0].name, + packages = self.packages(), lib = self.abi.prefix, - path = self.manifest_dir.display().to_string(), - features = features.join(", "), ); + let header = crate::c::header(self.abi, &self.packages()); let mut generator = Gen { abi: self.abi, - krate: self.abi.api.krate.clone(), + packages: self.packages(), out: String::new(), }; generator.lib(); 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) } } struct Gen<'a> { abi: &'a Abi<'a>, - krate: String, + /// The exported packages, for the docs. + packages: String, out: String, } @@ -96,8 +125,9 @@ impl Gen<'_> { self.out.push('\n'); } + /// A Rust path, through the crate's dependency on its export. fn path(&self, path: &[String]) -> String { - format!("{}::{}", self.krate, 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. @@ -266,8 +296,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..69455670c 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 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/src/main.rs b/ffi/src/main.rs index dd68e48a6..d004395cd 100644 --- a/ffi/src/main.rs +++ b/ffi/src/main.rs @@ -14,6 +14,7 @@ extern crate rustc_hir; extern crate rustc_infer; extern crate rustc_interface; extern crate rustc_middle; +extern crate rustc_session; extern crate rustc_span; extern crate rustc_trait_selection; 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..064e8c365 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), @@ -113,7 +119,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,13 +134,15 @@ 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] 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. @@ -172,3 +180,346 @@ 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}" + ); +} + +/// A one-function package named `name` in its own workspace, written into scratch. +fn package(name: &str) -> PathBuf { + let package = scratch().join(name); + std::fs::create_dir_all(package.join("src")).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"), format!("package = \"{name}\"\n")).unwrap(); + package +} + +/// 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(); + 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")); + cmd +} + +/// 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"]).output().unwrap(); + 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"), &[]).output().unwrap(); + 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, whatever target +/// cargo's config names: 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"), &[]) + .env("CARGO_BUILD_TARGET", "x86_64-pc-windows-msvc") + .output() + .unwrap(); + assert!( + output.status.success(), + "the census failed:\n{}", + 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) + ); +} + +/// Another export is found in the checked package's own graph, read plainly: a root +/// package that the workspace's default members leave out, under forced color, reaching +/// an export twice. +#[test] +fn diamond() { + let package = package("diamond"); + 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(); + }; + // `left` and `right` both depend on the export `shared`, which depends on `leaf`. + for (name, deps) in [ + ("app", ""), + ( + "left", + "shared = { package = \"diamond-shared\", path = \"../shared\" }\n", + ), + ( + "right", + "shared = { package = \"diamond-shared\", path = \"../shared\" }\n", + ), + ("shared", "leaf = { package = \"diamond-leaf\", path = \"../leaf\" }\n"), + ("leaf", ""), + ] { + write( + &format!("{name}/Cargo.toml"), + &format!( + "[package]\nname = \"diamond-{name}\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n[dependencies]\n{deps}" + ), + ); + write( + &format!("{name}/src/lib.rs"), + "pub struct Size {\n\tpub width: u32,\n}\n", + ); + } + write( + "Cargo.toml", + "[package]\nname = \"diamond\"\nversion = \"0.0.0\"\nedition = \"2024\"\n\n\ + [dependencies]\nleft = { package = \"diamond-left\", path = \"left\" }\n\ + right = { package = \"diamond-right\", path = \"right\" }\n\ + shared = { package = \"diamond-shared\", path = \"shared\" }\n\n\ + [workspace]\nmembers = [\"app\", \"left\", \"right\", \"shared\", \"leaf\"]\ndefault-members = [\"app\"]\n", + ); + write( + "src/lib.rs", + "pub fn width(size: shared::Size) -> 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) + ); +} + +/// 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 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"); + 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`. + 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\nimpl Size {\n\tpub fn new(width: u32) -> Self {\n\t\tSelf { width }\n\t}\n}\n", + ); + } + write( + "Cargo.toml", + "[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", + ); + write( + "src/lib.rs", + "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(); + 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 its dependency `shared`, and it uses 0 crates of it" + ), + "{stderr}" + ); + + write( + "src/lib.rs", + "pub fn total(a: shared::Size, b: bytes::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"))); +} + +/// Another export patched in for a registry dependency is bound by its resolved package, +/// though the dependency names no path, and the glue builds it patched in too. +#[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 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 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"))); +} diff --git a/quest/m1/ffi-export.md b/quest/m1/ffi-export.md index bd6918e84..68ea66c2e 100644 --- a/quest/m1/ffi-export.md +++ b/quest/m1/ffi-export.md @@ -19,9 +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. - Export each with its own census, naming another export's types across crates, as - moq-pattern's re-exports already need. +- **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.