From 1879b0080ba79b21716d5bd27175e7baf8704fa1 Mon Sep 17 00:00:00 2001 From: Michael Ilyin Date: Thu, 13 Aug 2026 23:35:11 +0200 Subject: [PATCH 1/2] Ask rustc whether the emitted Rust compiles (#198, step 2 in part) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `plan` said the generator produced a file. That is a weak claim: an emission can be well-formed, contain every substring a unit test looks for, and still not type-check — `examples/emitcheck` exists because that happened once with 41 of 41 tests green over it. Every cell that produced Rust is now compiled. The state is a **receipt**, not a claim. Each cell is written to `.rs`, the whole crate is checked in one pass, and each diagnostic is attributed back by the file rustc names. Nothing maps a cell to a fixture by hand — that mapping is what let #175's test pass without creating its own precondition. Verified to discriminate: `the_compile_check_separates_good_from_bad` feeds one compiling and one non-compiling unit through the real path and requires them separated. Compiler messages stay OUT of the committed report — they vary by toolchain, and `cargo test --all` runs on both 1.85 and stable, so a message in the file would make the report disagree with itself across jobs. Failing cells print their diagnostics on stderr. The check crate's dependencies are pinned exactly for the same reason: it has its own lockfile, so a caret range would let an upstream release move a cell with nothing in this repo having changed. **Exit: answers move.** 138 of 288 cells, in four classes: * 120 `plan` -> `rustc` — the new evidence. * 10 `plan` -> `bad rust` — emitted Rust that does not compile. See below. * 8 -> n/a — `Option<&T>` and `Vec<&T>` in a field or payload. Those fixtures were never legal Rust; the borrow rule only excused a spelling *starting* with `&`, so a borrow nested inside one was being measured against a struct that needs a lifetime parameter to exist. * no cell moved between `rejected` and `panic`. Three harness defects found by turning the compiler on, each of which had been producing a confident wrong answer: * the source crate was mounted as `mod probe` beside a generated `pub fn probe` wrapper — two different things sharing a name, so it is `flat` now; * a returned borrow has no lifetime to elide from, so `-> &Handle` is not Rust. Every borrow-returning cell had reported `plan` for a fixture that could not compile; the fixture writes `'static` now; * `impl Display for ZError` was being fed to the *model*, which correctly refuses an item kind the flat language does not have — failing 32 cells for a reason unrelated to their shape. The model now sees the four item kinds a `#[prebindgen]` surface declares, the same filter `emitcheck` applies. What the 10 findings are, all confirmed in the generator's own output rather than in fixture scaffolding: * JNI emits `Cow<'static, str>` **unqualified** into the consumer's scope, so a consumer that has not imported `Cow` cannot compile the file (3 cells); * C emits `flat::Option` — a std type qualified into the source module; * C moves out of a value behind a raw pointer for `Option` and `Option` parameters; * C calls the source function with one argument too many for a `&[T]` return, and builds a `map`/`collect` over a `Vec<&T>` return whose closure is a function item of the wrong signature; * JNI mismatches types for a `&mut T` parameter and for `&mut MaybeUninit`. Part of #198, tracked by #399. --- examples/shape-matrix/REPORT.md | 223 +++++++++++------------ examples/shape-matrix/src/check.rs | 265 ++++++++++++++++++++++++++++ examples/shape-matrix/src/corpus.rs | 42 ++++- examples/shape-matrix/src/lib.rs | 5 + examples/shape-matrix/src/report.rs | 102 +++++++++-- examples/shape-matrix/src/run.rs | 133 +++++++++++--- examples/shape-matrix/src/tests.rs | 58 +++++- 7 files changed, 668 insertions(+), 160 deletions(-) create mode 100644 examples/shape-matrix/src/check.rs diff --git a/examples/shape-matrix/REPORT.md b/examples/shape-matrix/REPORT.md index cd6e82cb..7f6af078 100644 --- a/examples/shape-matrix/REPORT.md +++ b/examples/shape-matrix/REPORT.md @@ -10,62 +10,69 @@ never by a hand-written list of what is supposed to work. See | Cell | Meaning | |---|---| -| `plan` | generation succeeded. Nothing here compiled, linked or ran the result. | +| `rustc` | the generator produced Rust **and rustc accepted it**. Neither cbindgen nor the Kotlin compiler has seen it. | +| **`bad rust`** | the generator produced Rust that does not compile. Green unit tests can coexist with this — that is why the check exists. | +| `plan` | generation succeeded and the compile check did not run (see the run's stderr). | | `rejected` | the generator refused the shape **and said why**. The intended outcome for anything unsupported. | | **`panic`** | the generator refused it without a diagnosis — the user gets a stack trace instead of a sentence ([#191](https://github.com/milyin/prebindgen/issues/191)). | | `—` | the placement is not legal Rust, so there is nothing to ask. | -`plan` is evidence, not a guarantee: `ToolchainCompiled` and `RuntimeExercised` -are the states that require a compiler and a runtime, and neither is collected -yet. +`rustc` is evidence, not a guarantee. It is the Rust half of +`ToolchainCompiled`: the emitted Rust type-checks against the fixture the way a +binding crate compiles it. The C header, the Kotlin classes and every +`RuntimeExercised` cell still require toolchains this stage does not run. + +Compiler messages are deliberately **not** in this file — they vary by +toolchain, and the report has to be identical on every one that builds it. A +failing cell prints its diagnostics on the run's stderr. ## Summary -| Target | plan | rejected | panic | n/a | -|---|---:|---:|---:|---:| -| C | 50 | 41 | 35 | 18 | -| Kotlin/JNI | 82 | 34 | 10 | 18 | +| Target | rustc | bad rust | plan only | rejected | panic | n/a | +|---|---:|---:|---:|---:|---:|---:| +| C | 45 | 5 | 0 | 41 | 31 | 22 | +| Kotlin/JNI | 75 | 5 | 0 | 34 | 8 | 22 | ## Position: parameter | Shape | Rust | C | Kotlin/JNI | |---|---|---|---| -| `scalar` | `u64` | plan | plan | -| `bool` | `bool` | plan | plan | +| `scalar` | `u64` | rustc | rustc | +| `bool` | `bool` | rustc | rustc | | `unit` | `()` | rejected | rejected | -| `string` | `String` | plan | plan | -| `str_ref` | `&str` | plan | plan | -| `record` | `Rec` | plan | plan | -| `handle` | `Handle` | plan | plan | -| `sum` | `Sum` | plan | plan | -| `unit_enum` | `Mode` | plan | plan | -| `shared_ref` | `&Rec` | rejected | plan | -| `exclusive_ref` | `&mut Rec` | rejected | plan | -| `handle_ref` | `&Handle` | plan | plan | -| `out_param` | `&mut MaybeUninit` | rejected | plan | -| `opt_scalar` | `Option` | plan | plan | +| `string` | `String` | rustc | rustc | +| `str_ref` | `&str` | rustc | rustc | +| `record` | `Rec` | rustc | rustc | +| `handle` | `Handle` | rustc | rustc | +| `sum` | `Sum` | rustc | rustc | +| `unit_enum` | `Mode` | rustc | rustc | +| `shared_ref` | `&Rec` | rejected | rustc | +| `exclusive_ref` | `&mut Rec` | rejected | **bad rust** | +| `handle_ref` | `&Handle` | rustc | rustc | +| `out_param` | `&mut MaybeUninit` | rejected | **bad rust** | +| `opt_scalar` | `Option` | rustc | rustc | | `vec_scalar` | `Vec` | rejected | rejected | -| `slice_scalar` | `&[u64]` | plan | rejected | +| `slice_scalar` | `&[u64]` | rustc | rejected | | `slice_mut_scalar` | `&mut [u64]` | rejected | rejected | -| `array_scalar` | `[u8; 4]` | rejected | plan | +| `array_scalar` | `[u8; 4]` | rejected | rustc | | `boxed_scalar` | `Box` | rejected | rejected | -| `cow_str` | `Cow<'static, str>` | rejected | plan | -| `opt_record` | `Option` | plan | plan | -| `opt_handle` | `Option` | plan | plan | -| `opt_ref` | `Option<&Handle>` | plan | plan | -| `vec_record` | `Vec` | rejected | plan | +| `cow_str` | `Cow<'static, str>` | rejected | **bad rust** | +| `opt_record` | `Option` | **bad rust** | rustc | +| `opt_handle` | `Option` | rustc | rustc | +| `opt_ref` | `Option<&Handle>` | rustc | rustc | +| `vec_record` | `Vec` | rejected | rustc | | `vec_handle` | `Vec` | rejected | **panic** | | `vec_ref` | `Vec<&Handle>` | rejected | **panic** | -| `vec_sum` | `Vec` | rejected | plan | -| `opt_sum` | `Option` | plan | plan | +| `vec_sum` | `Vec` | rejected | rustc | +| `opt_sum` | `Option` | **bad rust** | rustc | | `array_record` | `[Rec; 2]` | rejected | rejected | | `opt_vec` | `Option>` | rejected | rejected | -| `vec_opt` | `Vec>` | rejected | plan | +| `vec_opt` | `Vec>` | rejected | rustc | | `result_scalar` | `Result` | rejected | rejected | | `result_handle` | `Result` | rejected | rejected | | `result_sum_err` | `Result` | rejected | rejected | -| `callback` | `impl Fn(u64) + Send + Sync + 'static` | rejected | plan | -| `callback_handle` | `impl Fn(Handle) + Send + Sync + 'static` | rejected | plan | +| `callback` | `impl Fn(u64) + Send + Sync + 'static` | rejected | rustc | +| `callback_handle` | `impl Fn(Handle) + Send + Sync + 'static` | rejected | rustc |
What the generators said @@ -109,55 +116,55 @@ yet. | Shape | Rust | C | Kotlin/JNI | |---|---|---|---| -| `scalar` | `u64` | plan | plan | -| `bool` | `bool` | plan | plan | -| `unit` | `()` | plan | plan | -| `string` | `String` | plan | plan | -| `str_ref` | `&str` | rejected | plan | -| `record` | `Rec` | plan | plan | -| `handle` | `Handle` | plan | plan | -| `sum` | `Sum` | plan | plan | -| `unit_enum` | `Mode` | plan | plan | +| `scalar` | `u64` | rustc | rustc | +| `bool` | `bool` | rustc | rustc | +| `unit` | `()` | rustc | rustc | +| `string` | `String` | rustc | rustc | +| `str_ref` | `&str` | rejected | rustc | +| `record` | `Rec` | rustc | rustc | +| `handle` | `Handle` | rustc | rustc | +| `sum` | `Sum` | rustc | rustc | +| `unit_enum` | `Mode` | rustc | rustc | | `shared_ref` | `&Rec` | rejected | rejected | | `exclusive_ref` | `&mut Rec` | rejected | rejected | -| `handle_ref` | `&Handle` | plan | plan | +| `handle_ref` | `&Handle` | rustc | rustc | | `out_param` | `&mut MaybeUninit` | rejected | rejected | -| `opt_scalar` | `Option` | plan | plan | -| `vec_scalar` | `Vec` | plan | plan | -| `slice_scalar` | `&[u64]` | plan | rejected | +| `opt_scalar` | `Option` | rustc | rustc | +| `vec_scalar` | `Vec` | rustc | rustc | +| `slice_scalar` | `&[u64]` | **bad rust** | rejected | | `slice_mut_scalar` | `&mut [u64]` | rejected | rejected | -| `array_scalar` | `[u8; 4]` | rejected | plan | +| `array_scalar` | `[u8; 4]` | rejected | rustc | | `boxed_scalar` | `Box` | rejected | rejected | -| `cow_str` | `Cow<'static, str>` | rejected | plan | -| `opt_record` | `Option` | plan | plan | -| `opt_handle` | `Option` | plan | plan | -| `opt_ref` | `Option<&Handle>` | plan | plan | -| `vec_record` | `Vec` | plan | plan | -| `vec_handle` | `Vec` | plan | plan | -| `vec_ref` | `Vec<&Handle>` | plan | plan | -| `vec_sum` | `Vec` | plan | plan | -| `opt_sum` | `Option` | plan | plan | +| `cow_str` | `Cow<'static, str>` | rejected | rustc | +| `opt_record` | `Option` | rustc | rustc | +| `opt_handle` | `Option` | rustc | rustc | +| `opt_ref` | `Option<&Handle>` | rustc | rustc | +| `vec_record` | `Vec` | rustc | rustc | +| `vec_handle` | `Vec` | rustc | rustc | +| `vec_ref` | `Vec<&Handle>` | **bad rust** | rustc | +| `vec_sum` | `Vec` | rustc | rustc | +| `opt_sum` | `Option` | rustc | rustc | | `array_record` | `[Rec; 2]` | rejected | rejected | -| `opt_vec` | `Option>` | plan | plan | -| `vec_opt` | `Vec>` | **panic** | plan | -| `result_scalar` | `Result` | plan | plan | -| `result_handle` | `Result` | plan | plan | +| `opt_vec` | `Option>` | rustc | rustc | +| `vec_opt` | `Vec>` | **panic** | rustc | +| `result_scalar` | `Result` | rustc | rustc | +| `result_handle` | `Result` | rustc | rustc | | `result_sum_err` | `Result` | **panic** | rejected | | `callback` | `impl Fn(u64) + Send + Sync + 'static` | rejected | rejected | | `callback_handle` | `impl Fn(Handle) + Send + Sync + 'static` | rejected | rejected |
What the generators said -- `str_ref` / C: 2 required type(s) could not be resolved: — error: unresolved prebindgen output type `& str` — error: unresolved prebindgen output type `str` -- `shared_ref` / C: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `& Rec` -- `shared_ref` / Kotlin/JNI: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `& Rec` -- `exclusive_ref` / C: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `& mut Rec` -- `exclusive_ref` / Kotlin/JNI: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `& mut Rec` -- `out_param` / C: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `& mut MaybeUninit < u64 >` -- `out_param` / Kotlin/JNI: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `& mut MaybeUninit < u64 >` -- `slice_scalar` / Kotlin/JNI: 2 required type(s) could not be resolved: — error: unresolved prebindgen output type `& [u64]` — error: unresolved prebindgen output type `[u64]` -- `slice_mut_scalar` / C: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `& mut [u64]` -- `slice_mut_scalar` / Kotlin/JNI: 2 required type(s) could not be resolved: — error: unresolved prebindgen output type `& mut [u64]` — error: unresolved prebindgen output type `[u64]` +- `str_ref` / C: 2 required type(s) could not be resolved: — error: unresolved prebindgen output type `& 'static str` — error: unresolved prebindgen output type `str` +- `shared_ref` / C: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `& 'static Rec` +- `shared_ref` / Kotlin/JNI: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `& 'static Rec` +- `exclusive_ref` / C: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `& 'static mut Rec` +- `exclusive_ref` / Kotlin/JNI: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `& 'static mut Rec` +- `out_param` / C: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `& 'static mut MaybeUninit < u64 >` +- `out_param` / Kotlin/JNI: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `& 'static mut MaybeUninit < u64 >` +- `slice_scalar` / Kotlin/JNI: 2 required type(s) could not be resolved: — error: unresolved prebindgen output type `& 'static [u64]` — error: unresolved prebindgen output type `[u64]` +- `slice_mut_scalar` / C: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `& 'static mut [u64]` +- `slice_mut_scalar` / Kotlin/JNI: 2 required type(s) could not be resolved: — error: unresolved prebindgen output type `& 'static mut [u64]` — error: unresolved prebindgen output type `[u64]` - `array_scalar` / C: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `[u8 ; 4]` - `boxed_scalar` / C: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `Box < u64 >` - `boxed_scalar` / Kotlin/JNI: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `Box < u64 >` @@ -178,34 +185,34 @@ yet. | Shape | Rust | C | Kotlin/JNI | |---|---|---|---| -| `scalar` | `u64` | plan | plan | -| `bool` | `bool` | plan | plan | +| `scalar` | `u64` | rustc | rustc | +| `bool` | `bool` | rustc | rustc | | `unit` | `()` | **panic** | rejected | -| `string` | `String` | plan | plan | +| `string` | `String` | rustc | rustc | | `str_ref` | `&str` | — | — | -| `record` | `Rec` | **panic** | plan | -| `handle` | `Handle` | **panic** | plan | -| `sum` | `Sum` | plan | plan | -| `unit_enum` | `Mode` | **panic** | plan | +| `record` | `Rec` | **panic** | rustc | +| `handle` | `Handle` | **panic** | rustc | +| `sum` | `Sum` | rustc | rustc | +| `unit_enum` | `Mode` | **panic** | rustc | | `shared_ref` | `&Rec` | — | — | | `exclusive_ref` | `&mut Rec` | — | — | | `handle_ref` | `&Handle` | — | — | | `out_param` | `&mut MaybeUninit` | — | — | -| `opt_scalar` | `Option` | **panic** | plan | +| `opt_scalar` | `Option` | **panic** | rustc | | `vec_scalar` | `Vec` | **panic** | rejected | | `slice_scalar` | `&[u64]` | — | — | | `slice_mut_scalar` | `&mut [u64]` | — | — | -| `array_scalar` | `[u8; 4]` | **panic** | plan | +| `array_scalar` | `[u8; 4]` | **panic** | rustc | | `boxed_scalar` | `Box` | **panic** | rejected | -| `cow_str` | `Cow<'static, str>` | **panic** | plan | -| `opt_record` | `Option` | **panic** | plan | -| `opt_handle` | `Option` | **panic** | plan | -| `opt_ref` | `Option<&Handle>` | **panic** | plan | -| `vec_record` | `Vec` | **panic** | plan | +| `cow_str` | `Cow<'static, str>` | **panic** | **bad rust** | +| `opt_record` | `Option` | **panic** | rustc | +| `opt_handle` | `Option` | **panic** | rustc | +| `opt_ref` | `Option<&Handle>` | — | — | +| `vec_record` | `Vec` | **panic** | rustc | | `vec_handle` | `Vec` | **panic** | **panic** | -| `vec_ref` | `Vec<&Handle>` | **panic** | **panic** | +| `vec_ref` | `Vec<&Handle>` | — | — | | `vec_sum` | `Vec` | **panic** | **panic** | -| `opt_sum` | `Option` | **panic** | plan | +| `opt_sum` | `Option` | **panic** | rustc | | `array_record` | `[Rec; 2]` | **panic** | rejected | | `opt_vec` | `Option>` | **panic** | rejected | | `vec_opt` | `Vec>` | **panic** | **panic** | @@ -231,12 +238,9 @@ yet. - `cow_str` / C: Cbindgen: field `v` of data struct `Probe` has unsupported type `Cow < 'static , str >` - `opt_record` / C: Cbindgen: field `v` of data struct `Probe` has unsupported type `Option < Rec >` - `opt_handle` / C: Cbindgen: field `v` of data struct `Probe` has unsupported type `Option < Handle >` -- `opt_ref` / C: Cbindgen: field `v` of data struct `Probe` has unsupported type `Option < & Handle >` - `vec_record` / C: Cbindgen: field `v` of data struct `Probe` has unsupported type `Vec < Rec >` - `vec_handle` / C: Cbindgen: field `v` of data struct `Probe` has unsupported type `Vec < Handle >` - `vec_handle` / Kotlin/JNI: JniGen: `Vec` is unsupported — its elements would be closeable native handles (jlong) the JVM must free individually. Expose a per-element accessor instead of returning a `Vec` of handles. -- `vec_ref` / C: Cbindgen: field `v` of data struct `Probe` has unsupported type `Vec < & Handle >` -- `vec_ref` / Kotlin/JNI: JniGen: `Vec<& Handle>` is unsupported — its elements would be closeable native handles (jlong) the JVM must free individually. Expose a per-element accessor instead of returning a `Vec` of handles. - `vec_sum` / C: Cbindgen: field `v` of data struct `Probe` has unsupported type `Vec < Sum >` - `vec_sum` / Kotlin/JNI: fromParts bridge: `Vec` sealed-class field (`Probe.v`) is not supported (variable arity) - `opt_sum` / C: Cbindgen: field `v` of data struct `Probe` has unsupported type `Option < Sum >` @@ -259,40 +263,40 @@ yet. | Shape | Rust | C | Kotlin/JNI | |---|---|---|---| -| `scalar` | `u64` | plan | plan | -| `bool` | `bool` | plan | plan | +| `scalar` | `u64` | rustc | rustc | +| `bool` | `bool` | rustc | rustc | | `unit` | `()` | rejected | **panic** | -| `string` | `String` | plan | plan | +| `string` | `String` | rustc | rustc | | `str_ref` | `&str` | — | — | -| `record` | `Rec` | plan | plan | -| `handle` | `Handle` | plan | plan | -| `sum` | `Sum` | plan | **panic** | -| `unit_enum` | `Mode` | plan | plan | +| `record` | `Rec` | rustc | rustc | +| `handle` | `Handle` | rustc | rustc | +| `sum` | `Sum` | rustc | **panic** | +| `unit_enum` | `Mode` | rustc | rustc | | `shared_ref` | `&Rec` | — | — | | `exclusive_ref` | `&mut Rec` | — | — | | `handle_ref` | `&Handle` | — | — | | `out_param` | `&mut MaybeUninit` | — | — | -| `opt_scalar` | `Option` | **panic** | plan | +| `opt_scalar` | `Option` | **panic** | rustc | | `vec_scalar` | `Vec` | **panic** | rejected | | `slice_scalar` | `&[u64]` | — | — | | `slice_mut_scalar` | `&mut [u64]` | — | — | -| `array_scalar` | `[u8; 4]` | rejected | plan | +| `array_scalar` | `[u8; 4]` | rejected | rustc | | `boxed_scalar` | `Box` | rejected | rejected | -| `cow_str` | `Cow<'static, str>` | rejected | plan | -| `opt_record` | `Option` | **panic** | plan | -| `opt_handle` | `Option` | plan | plan | -| `opt_ref` | `Option<&Handle>` | **panic** | plan | -| `vec_record` | `Vec` | **panic** | plan | +| `cow_str` | `Cow<'static, str>` | rejected | **bad rust** | +| `opt_record` | `Option` | **panic** | rustc | +| `opt_handle` | `Option` | **bad rust** | rustc | +| `opt_ref` | `Option<&Handle>` | — | — | +| `vec_record` | `Vec` | **panic** | rustc | | `vec_handle` | `Vec` | **panic** | **panic** | -| `vec_ref` | `Vec<&Handle>` | **panic** | **panic** | +| `vec_ref` | `Vec<&Handle>` | — | — | | `vec_sum` | `Vec` | **panic** | rejected | | `opt_sum` | `Option` | **panic** | rejected | | `array_record` | `[Rec; 2]` | rejected | rejected | | `opt_vec` | `Option>` | rejected | rejected | -| `vec_opt` | `Vec>` | **panic** | plan | -| `result_scalar` | `Result` | rejected | plan | -| `result_handle` | `Result` | rejected | plan | -| `result_sum_err` | `Result` | rejected | plan | +| `vec_opt` | `Vec>` | **panic** | rustc | +| `result_scalar` | `Result` | rejected | rustc | +| `result_handle` | `Result` | rejected | rustc | +| `result_sum_err` | `Result` | rejected | rustc | | `callback` | `impl Fn(u64) + Send + Sync + 'static` | — | — | | `callback_handle` | `impl Fn(Handle) + Send + Sync + 'static` | — | — | @@ -309,12 +313,9 @@ yet. - `boxed_scalar` / Kotlin/JNI: 1 required type(s) could not be resolved: — error: unresolved prebindgen output type `Box < u64 >` - `cow_str` / C: 4 required type(s) could not be resolved: — error: unresolved prebindgen input type `Probe` — error: unresolved prebindgen output type `Probe` — error: unresolved prebindgen input type `Cow < 'static , str >` — error: unresolved prebindg… - `opt_record` / C: Cbindgen::tagged_union: payload `Probe::Carried` of type `Option < Rec >` cannot cross: its input and output converters disagree on the wire (`* const rec` in, `()` out) and one union field serves both directions -- `opt_ref` / C: Cbindgen::tagged_union: payload `Probe::Carried` of type `Option < & Handle >` cannot cross: its input and output converters disagree on the wire (`* const handle` in, `()` out) and one union field serves both directions - `vec_record` / C: Cbindgen::tagged_union: payload `Probe::Carried` of type `Vec < Rec >` cannot cross: a `Vec` needs TWO C wires (pointer + length) and one union field carries only one, so its length would be silently dropped — hand the sequence over thro… - `vec_handle` / C: Cbindgen::tagged_union: payload `Probe::Carried` of type `Vec < Handle >` cannot cross: a `Vec` needs TWO C wires (pointer + length) and one union field carries only one, so its length would be silently dropped — hand the sequence over t… - `vec_handle` / Kotlin/JNI: JniGen: `Vec` is unsupported — its elements would be closeable native handles (jlong) the JVM must free individually. Expose a per-element accessor instead of returning a `Vec` of handles. -- `vec_ref` / C: Cbindgen::tagged_union: payload `Probe::Carried` of type `Vec < & Handle >` cannot cross: a `Vec` needs TWO C wires (pointer + length) and one union field carries only one, so its length would be silently dropped — hand the sequence over… -- `vec_ref` / Kotlin/JNI: JniGen: `Vec<& Handle>` is unsupported — its elements would be closeable native handles (jlong) the JVM must free individually. Expose a per-element accessor instead of returning a `Vec` of handles. - `vec_sum` / C: Cbindgen::tagged_union: payload `Probe::Carried` of type `Vec < Sum >` cannot cross: a `Vec` needs TWO C wires (pointer + length) and one union field carries only one, so its length would be silently dropped — hand the sequence over thro… - `vec_sum` / Kotlin/JNI: 2 required type(s) could not be resolved: — error: unresolved prebindgen output type `Vec < Sum >` — error: unresolved prebindgen output type `Sum` - `opt_sum` / C: Cbindgen::tagged_union: payload `Probe::Carried` of type `Option < Sum >` cannot cross: its input and output converters disagree on the wire (`* const :: core :: mem :: MaybeUninit < sum >` in, `()` out) and one union field serves both d… diff --git a/examples/shape-matrix/src/check.rs b/examples/shape-matrix/src/check.rs new file mode 100644 index 00000000..82ffd066 --- /dev/null +++ b/examples/shape-matrix/src/check.rs @@ -0,0 +1,265 @@ +//! Does the emitted Rust actually compile? +//! +//! `plan` only says the generator produced a file. An emission can be +//! well-formed, contain every substring a unit test looks for, and still not +//! type-check — [`examples/emitcheck`](../../emitcheck) exists because that +//! happened once with 41 of 41 tests green over it. This stage asks rustc, per +//! cell. +//! +//! # The answer is a receipt, not a claim +//! +//! A cell is recorded as compiling only if **rustc says so about that cell's own +//! file**. Every cell is written to `.rs`, the whole crate is checked in one +//! pass, and each diagnostic is attributed back by the file path the compiler +//! reports. Nothing maps a cell to a fixture by name — a name-keyed mapping can +//! claim coverage for a fixture that never touched the cell, which is the defect +//! #175 was. +//! +//! # What it does not cover +//! +//! rustc, and rustc only. Neither `cbindgen` nor the Kotlin compiler runs here, +//! so a cell that compiles has not been shown to produce a valid C header or a +//! loadable JVM class. Those are the rest of `ToolchainCompiled`, and they are +//! not collected yet. + +use std::{ + collections::{BTreeMap, BTreeSet}, + path::{Path, PathBuf}, + process::Command, +}; + +/// One cell's emitted Rust, ready to be checked. +pub struct Unit { + /// `____`, the receipt key. + pub id: String, + /// The fixture's own items — what the source crate would contain. + pub fixture: String, + /// What the generator emitted for it. + pub emitted: String, +} + +/// Which cells rustc accepted, and what it said about the rest. +#[derive(Default)] +pub struct Checked { + pub compiled: BTreeSet, + /// Cell id → the diagnostics rustc reported for it. **Not** rendered into + /// the committed report: a message is a property of the compiler version, + /// and the report has to be identical on every toolchain that builds it. + pub failed: BTreeMap>, +} + +/// Check every unit in one crate, and attribute the result per cell. +/// +/// `Err` is reserved for the check itself failing to run — a missing cargo, an +/// unwritable directory. That is not a verdict about any cell, and callers must +/// not record it as one. +/// `workspace` names the directory this batch is checked in. Two batches must +/// not share one: the report's run and the self-test run concurrently under +/// `cargo test`, and a shared directory would have them overwriting each +/// other's sources. A *stable* name per batch rather than a unique one per +/// call, so the dependencies stay compiled between runs and the emitted Rust +/// stays on disk to be read after a failure. +pub fn check(workspace: &str, units: &[Unit]) -> Result { + if units.is_empty() { + return Ok(Checked::default()); + } + let root = crate_dir()?.join(workspace); + write_crate(&root, units)?; + + let output = Command::new(std::env::var("CARGO").unwrap_or_else(|_| "cargo".into())) + .arg("check") + .arg("--quiet") + .arg("--message-format=json") + .arg("--manifest-path") + .arg(root.join("Cargo.toml")) + // Its own target directory: the parent build may still hold the + // workspace one, and a nested cargo blocking on that lock would look + // like a hang rather than a queue. + .arg("--target-dir") + .arg(root.join("target")) + .output() + .map_err(|e| format!("running cargo check: {e}"))?; + + let mut checked = Checked::default(); + for unit in units { + checked.compiled.insert(unit.id.clone()); + } + for line in String::from_utf8_lossy(&output.stdout).lines() { + let Some((file, message)) = diagnostic(line) else { + continue; + }; + let Some(id) = cell_of(&file) else { continue }; + checked.compiled.remove(&id); + checked.failed.entry(id).or_default().push(message); + } + + // A crate that failed to build with no attributable diagnostic means the + // failure was the harness's, not a cell's — a bad `Cargo.toml`, a missing + // dependency. Reporting every cell as compiling would be a lie in the + // direction that hides defects. + if !output.status.success() && checked.failed.is_empty() { + return Err(format!( + "cargo check failed with nothing attributable to a cell:\n{}", + String::from_utf8_lossy(&output.stderr) + )); + } + Ok(checked) +} + +/// The file and message of one rustc error, or `None` for anything else on the +/// JSON stream. +/// +/// Deliberately hand-parsed rather than pulled in with `serde_json`: this reads +/// two fields of a stable format, and the alternative is a dependency in a crate +/// whose whole point is to have no opinions of its own. +fn diagnostic(line: &str) -> Option<(String, String)> { + if !line.contains("\"level\":\"error\"") { + return None; + } + let rendered = field(line, "\"rendered\":\"")?; + let file = rendered + .split(&['\\', '"'][..]) + .find(|part| part.ends_with(".rs") && part.contains(CELL_SEP)) + .or_else(|| { + rendered + .split_whitespace() + .find(|w| w.contains(".rs") && w.contains(CELL_SEP)) + })? + .to_string(); + let message = rendered.lines().next().unwrap_or(&rendered).to_string(); + Some((file, message)) +} + +fn field(line: &str, key: &str) -> Option { + let start = line.find(key)? + key.len(); + let rest = &line[start..]; + let mut out = String::new(); + let mut chars = rest.chars(); + while let Some(c) = chars.next() { + match c { + '"' => break, + '\\' => match chars.next() { + Some('n') => out.push('\n'), + Some('"') => out.push('"'), + Some('\\') => out.push('\\'), + Some(other) => out.push(other), + None => break, + }, + other => out.push(other), + } + } + Some(out) +} + +/// The separator between a cell id's parts, chosen so a file name cannot be +/// mistaken for anything else on a diagnostic line. +const CELL_SEP: &str = "__"; + +fn cell_of(file: &str) -> Option { + let stem = Path::new(file.trim_matches(|c: char| !c.is_ascii_graphic())) + .file_stem()? + .to_str()?; + stem.contains(CELL_SEP).then(|| stem.to_string()) +} + +/// Where the generated crate lives: under the workspace target directory, so it +/// is already ignored by git and cleaned by `cargo clean`. +fn crate_dir() -> Result { + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace = manifest + .parent() + .and_then(Path::parent) + .ok_or("locating the workspace root")?; + let target = std::env::var("CARGO_TARGET_DIR") + .map(PathBuf::from) + .unwrap_or_else(|_| workspace.join("target")); + Ok(target.join("shape-matrix-check")) +} + +fn write_crate(root: &Path, units: &[Unit]) -> Result<(), String> { + let src = root.join("src"); + std::fs::create_dir_all(&src).map_err(|e| format!("creating {}: {e}", src.display()))?; + + let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR")); + let workspace = manifest.parent().and_then(Path::parent).expect("workspace"); + write( + &root.join("Cargo.toml"), + &manifest_toml(&workspace.display().to_string()), + )?; + + let mut lib = String::from(LIB_HEADER); + for (n, unit) in units.iter().enumerate() { + let file = format!("{}.rs", unit.id); + write(&src.join(&file), &cell_source(unit))?; + lib.push_str(&format!("#[path = \"{file}\"]\npub mod cell_{n};\n")); + } + write(&src.join("lib.rs"), &lib) +} + +fn write(path: &Path, contents: &str) -> Result<(), String> { + std::fs::write(path, contents).map_err(|e| format!("writing {}: {e}", path.display())) +} + +/// One cell as a Rust module: the source crate, then the generated file. +/// +/// This mirrors how a binding crate is actually built — `pub mod myflat;` plus +/// `include!("generated_bindings.rs")` in `emitcheck`, the same two lines every +/// consumer writes. +/// +/// The imports go **inside** the fixture module and nowhere else. A source crate +/// writing `Cow<'static, str>` has imported `Cow`; the generated file is a +/// separate scope, and if it needs an import nobody gave it, that is a finding +/// about the generator rather than something for this harness to paper over. +fn cell_source(unit: &Unit) -> String { + format!( + "// Generated by shape-matrix. Do not edit.\n\ + #![allow(clippy::all, dead_code, unused_imports, unused_variables)]\n\ + \n\ + pub mod {} {{\n\ + use std::borrow::Cow;\n\ + use std::mem::MaybeUninit;\n\ + {}\n\ + }}\n\ + \n\ + {}\n", + crate::run::SOURCE_CRATE, + unit.fixture, + unit.emitted + ) +} + +fn manifest_toml(workspace: &str) -> String { + format!( + r#"# Generated by shape-matrix. Do not edit. +[package] +name = "shape-matrix-check" +version = "0.0.0" +edition = "2021" +publish = false + +[lib] +path = "src/lib.rs" + +# Exactly what generated code calls into — the dependencies a real binding +# crate declares, and nothing else. +[dependencies] +prebindgen-jni-runtime = {{ path = "{workspace}/prebindgen-jni-runtime" }} +prebindgen-c-runtime = {{ path = "{workspace}/prebindgen-c-runtime" }} +# Pinned exactly, and to what the workspace already resolves: this crate has +# its own lockfile, so a caret range would let a new upstream release change a +# cell's answer with nothing in this repo having changed. +jni = "=0.21.1" +tracing = "=0.1.44" +konst = "=0.3.17" + +[workspace] +"# + ) +} + +const LIB_HEADER: &str = "\ +// Generated by shape-matrix. Do not edit. +// +// One module per cell that produced Rust, each in its own file so a diagnostic +// names the cell it belongs to. +"; diff --git a/examples/shape-matrix/src/corpus.rs b/examples/shape-matrix/src/corpus.rs index 1b6e8bd5..4a82ae8d 100644 --- a/examples/shape-matrix/src/corpus.rs +++ b/examples/shape-matrix/src/corpus.rs @@ -24,18 +24,37 @@ pub enum Need { impl Need { /// The Rust the fixture declares for it. + /// + /// Everything derives `Clone`, as the types in a real source crate do. That + /// is not incidental: several generator paths clone — a borrowed handle + /// crossing out becomes an owned one — so a fixture whose types were not + /// `Clone` would spend its cells measuring that constraint instead of + /// whether the shape crosses. pub fn source(self) -> &'static str { match self { - Need::Record => "pub struct Rec { pub id: u64, pub tag: u32 }", - Need::Handle => "pub struct Handle { pub id: u64 }", - Need::Sum => "pub enum Sum { Num(u64), Nothing }", - Need::UnitEnum => "pub enum Mode { On = 0, Off = 1 }", + Need::Record => "#[derive(Clone)] pub struct Rec { pub id: u64, pub tag: u32 }", + Need::Handle => "#[derive(Clone)] pub struct Handle { pub id: u64 }", + // `Display` too: `result_sum_err` puts this in an error position, + // where both targets render the error as text. + Need::Sum => { + "#[derive(Clone)] pub enum Sum { Num(u64), Nothing }\n\ + impl std::fmt::Display for Sum {\n\ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n\ + write!(f, \"sum\") } }" + } + Need::UnitEnum => "#[derive(Clone, Copy)] pub enum Mode { On = 0, Off = 1 }", // Both targets need a way to render an error, so the accessor is // part of the declaration rather than something a cell goes // without. + // An error type is `Clone` and `Display` because that is what an + // error type is; a fixture without them spends its fallible cells + // measuring that requirement instead of whether the shape crosses. Need::Error => { - "pub struct ZError { pub code: u64 }\n\ - pub fn zerror_message(e: &ZError) -> String { unimplemented!() }" + "#[derive(Clone)] pub struct ZError { pub code: u64 }\n\ + impl std::fmt::Display for ZError {\n\ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {\n\ + write!(f, \"error {}\", self.code) } }\n\ + pub fn zerror_message(e: &ZError) -> String { unimplemented!() }" } } } @@ -90,6 +109,17 @@ impl Position { Position::Payload => "enum payload", } } + + /// The part of a cell id this position contributes — file-safe, since a + /// cell id names the file rustc reports diagnostics against. + pub fn slug(self) -> &'static str { + match self { + Position::Param => "param", + Position::Return => "ret", + Position::Field => "field", + Position::Payload => "payload", + } + } } /// The shapes. diff --git a/examples/shape-matrix/src/lib.rs b/examples/shape-matrix/src/lib.rs index 920e7564..7dca830c 100644 --- a/examples/shape-matrix/src/lib.rs +++ b/examples/shape-matrix/src/lib.rs @@ -36,8 +36,13 @@ //! legitimately answer differently, and one combined verdict would hide //! exactly the gaps this exists to find. //! +//! Every cell that produces Rust is then handed to rustc ([`check`]), because +//! "the generator produced a file" and "the file compiles" are different claims +//! and only the second is worth much. +//! //! Run it with `cargo run -p shape-matrix`, which rewrites `REPORT.md`. +pub mod check; pub mod corpus; pub mod report; pub mod run; diff --git a/examples/shape-matrix/src/report.rs b/examples/shape-matrix/src/report.rs index 2479d560..20120b2d 100644 --- a/examples/shape-matrix/src/report.rs +++ b/examples/shape-matrix/src/report.rs @@ -7,6 +7,7 @@ use std::fmt::Write as _; use crate::{ + check::{self, Unit}, corpus::{Position, Shape, SHAPES}, run::{declarations, run, ClassKind, State, Target}, tag::TypeTag, @@ -35,6 +36,32 @@ struct Cell { position: Position, target: Target, state: State, + /// Whether rustc accepted the emitted Rust. `None` when there was nothing + /// to check — the generator refused the cell, or the check could not run at + /// all, which is not a verdict about the cell either. + compiled: Option, +} + +impl Cell { + /// `____` — the receipt key, and the name of the + /// file rustc reports against. + fn id(&self) -> String { + format!( + "{}__{}__{}", + self.shape.id, + self.position.slug(), + self.target.slug() + ) + } + + /// What the table prints. + fn text(&self) -> String { + match (&self.state, self.compiled) { + (State::PlanSupported, Some(true)) => "rustc".to_string(), + (State::PlanSupported, Some(false)) => "**bad rust**".to_string(), + (state, _) => state.cell(), + } + } } fn run_all() -> Vec { @@ -44,42 +71,76 @@ fn run_all() -> Vec { std::panic::set_hook(Box::new(|_| {})); let mut cells = Vec::new(); + let mut units = Vec::new(); for shape in SHAPES { for position in Position::ALL { for target in Target::ALL { - cells.push(Cell { + let outcome = run(shape, *position, *target); + let cell = Cell { shape, position: *position, target: *target, - state: run(shape, *position, *target), - }); + state: outcome.state, + compiled: None, + }; + if let Some(emitted) = outcome.emitted { + units.push(Unit { + id: cell.id(), + fixture: crate::run::fixture_source(shape, *position), + emitted, + }); + } + cells.push(cell); } } } std::panic::set_hook(previous); + + // A check that could not run is reported as such and leaves every cell + // uncompiled, rather than being folded into the cells as a verdict they did + // not earn. + match check::check("cells", &units) { + Ok(checked) => { + for cell in &mut cells { + if matches!(cell.state, State::PlanSupported) { + cell.compiled = Some(checked.compiled.contains(&cell.id())); + } + } + for (id, messages) in &checked.failed { + eprintln!("shape-matrix: {id} emitted Rust that does not compile:"); + for message in messages { + eprintln!(" {message}"); + } + } + } + Err(err) => eprintln!("shape-matrix: the compile check did not run: {err}"), + } + cells } fn render_summary(out: &mut String, results: &[Cell]) { out.push_str("## Summary\n\n"); - out.push_str("| Target | plan | rejected | panic | n/a |\n"); - out.push_str("|---|---:|---:|---:|---:|\n"); + out.push_str("| Target | rustc | bad rust | plan only | rejected | panic | n/a |\n"); + out.push_str("|---|---:|---:|---:|---:|---:|---:|\n"); for target in Target::ALL { - let of = |f: fn(&State) -> bool| { + let of = |f: fn(&Cell) -> bool| { results .iter() - .filter(|c| c.target == *target && f(&c.state)) + .filter(|c| c.target == *target && f(c)) .count() }; let _ = writeln!( out, - "| {} | {} | {} | {} | {} |", + "| {} | {} | {} | {} | {} | {} | {} |", target.as_str(), - of(|s| matches!(s, State::PlanSupported)), - of(|s| matches!(s, State::Rejected(_))), - of(|s| matches!(s, State::Panicked(_))), - of(|s| matches!(s, State::NotApplicable(_))), + of(|c| c.compiled == Some(true)), + of(|c| c.compiled == Some(false)), + of(|c| matches!(c.state, State::PlanSupported) && c.compiled.is_none()), + of(|c| matches!(c.state, State::Rejected(_))), + of(|c| matches!(c.state, State::Panicked(_))), + of(|c| matches!(c.state, State::NotApplicable(_))), ); } out.push('\n'); @@ -96,7 +157,7 @@ fn render_position(out: &mut String, results: &[Cell], position: Position) { .find(|c| { std::ptr::eq(c.shape, shape) && c.position == position && c.target == target }) - .map(|c| c.state.cell()) + .map(|c| c.text()) .unwrap_or_default() }; let _ = writeln!( @@ -211,13 +272,20 @@ never by a hand-written list of what is supposed to work. See | Cell | Meaning | |---|---| -| `plan` | generation succeeded. Nothing here compiled, linked or ran the result. | +| `rustc` | the generator produced Rust **and rustc accepted it**. Neither cbindgen nor the Kotlin compiler has seen it. | +| **`bad rust`** | the generator produced Rust that does not compile. Green unit tests can coexist with this — that is why the check exists. | +| `plan` | generation succeeded and the compile check did not run (see the run\'s stderr). | | `rejected` | the generator refused the shape **and said why**. The intended outcome for anything unsupported. | | **`panic`** | the generator refused it without a diagnosis — the user gets a stack trace instead of a sentence ([#191](https://github.com/milyin/prebindgen/issues/191)). | | `—` | the placement is not legal Rust, so there is nothing to ask. | -`plan` is evidence, not a guarantee: `ToolchainCompiled` and `RuntimeExercised` -are the states that require a compiler and a runtime, and neither is collected -yet. +`rustc` is evidence, not a guarantee. It is the Rust half of +`ToolchainCompiled`: the emitted Rust type-checks against the fixture the way a +binding crate compiles it. The C header, the Kotlin classes and every +`RuntimeExercised` cell still require toolchains this stage does not run. + +Compiler messages are deliberately **not** in this file — they vary by +toolchain, and the report has to be identical on every one that builds it. A +failing cell prints its diagnostics on the run\'s stderr. "; diff --git a/examples/shape-matrix/src/run.rs b/examples/shape-matrix/src/run.rs index 4c251667..6bce62ee 100644 --- a/examples/shape-matrix/src/run.rs +++ b/examples/shape-matrix/src/run.rs @@ -16,8 +16,14 @@ use prebindgen_registry::{ExpandReturnDecl, FunctionDecl}; use crate::corpus::{Need, Position, Shape}; -/// The crate name every fixture item is stamped with. -const SOURCE_CRATE: &str = "probe"; +/// The crate name every fixture item is stamped with, and so the module the +/// generated code qualifies its calls through. +/// +/// Not `probe`: the fixture's function is called that, and a consumer mounting +/// the source crate as `mod probe` next to a generated `pub fn probe` wrapper +/// does not compile. The two names are different things and this crate found +/// out by conflating them. +pub const SOURCE_CRATE: &str = "flat"; /// The function every fixture declares to the target. const PROBE_FN: &str = "probe"; @@ -102,6 +108,15 @@ impl Target { Target::Jni => "Kotlin/JNI", } } + + /// The part of a cell id this target contributes. See + /// [`Position::slug`](crate::corpus::Position::slug). + pub fn slug(self) -> &'static str { + match self { + Target::C => "c", + Target::Jni => "jni", + } + } } /// Why a placement is not legal Rust to begin with. @@ -118,12 +133,38 @@ pub fn not_applicable(shape: &Shape, position: Position) -> Option<&'static str> if shape.spelling.contains("impl Fn") { return Some("`impl Trait` is not a field type"); } - if shape.spelling.starts_with('&') { + if shape.spelling.contains('&') { return Some("a borrowed field needs a lifetime parameter on its declaration"); } None } +/// The same spelling, with every borrow given an explicit `'static`. +/// +/// A returned borrow needs a lifetime and the probe function has no parameter +/// to elide one from, so `-> &Handle` is not Rust at all. Writing `'static` +/// keeps the shape and makes the fixture legal; the alternative — adding an +/// anchor parameter — would change what the cell measures. +/// +/// This was found the hard way: before it, every borrow-returning cell reported +/// `plan` for a fixture that could not have compiled. +fn anchored(spelling: &str) -> String { + let mut out = String::new(); + let mut chars = spelling.chars().peekable(); + while let Some(c) = chars.next() { + out.push(c); + if c != '&' { + continue; + } + // Already written with a lifetime — `Cow<'static, str>` and friends. + if chars.peek() == Some(&'\'') { + continue; + } + out.push_str("'static "); + } + out +} + /// The fixture's Rust source: the shape's supporting declarations, the wrapper /// declaration the position needs, and the function that makes it cross. pub fn fixture_source(shape: &Shape, position: Position) -> String { @@ -135,18 +176,23 @@ pub fn fixture_source(shape: &Shape, position: Position) -> String { items.push(format!("pub fn {PROBE_FN}(v: {ty}) {{ let _ = v; }}")); } Position::Return => { + let ty = anchored(ty); items.push(format!( "pub fn {PROBE_FN}() -> {ty} {{ unimplemented!() }}" )); } Position::Field => { - items.push(format!("pub struct {PROBE_TY} {{ pub v: {ty} }}")); + items.push(format!( + "#[derive(Clone)] pub struct {PROBE_TY} {{ pub v: {ty} }}" + )); items.push(format!( "pub fn {PROBE_FN}() -> {PROBE_TY} {{ unimplemented!() }}" )); } Position::Payload => { - items.push(format!("pub enum {PROBE_TY} {{ Carried({ty}), Empty }}")); + items.push(format!( + "#[derive(Clone)] pub enum {PROBE_TY} {{ Carried({ty}), Empty }}" + )); items.push(format!( "pub fn {PROBE_FN}() -> {PROBE_TY} {{ unimplemented!() }}" )); @@ -273,6 +319,18 @@ pub fn declarations(shape: &Shape, position: Position) -> Vec { decls } +/// The fixture's items, as the **model** sees them. +/// +/// Filtered to the four item kinds a `#[prebindgen]` surface declares, the same +/// filter `examples/emitcheck` applies to its own source file. A fixture is a +/// real Rust file and so carries things a flat API does not declare — an +/// `impl Display for ZError` is part of what makes the crate compile and is not +/// part of its boundary. +/// +/// Found by feeding them: an `impl` block reaches the frontend as *"is an item +/// kind the prebindgen source language does not model"*, which failed the whole +/// binding and turned 32 cells into rejections that had nothing to do with +/// their shape. fn items(source: &str) -> Vec<(syn::Item, SourceLocation)> { let loc = SourceLocation { crate_name: Some(SOURCE_CRATE.to_string()), @@ -282,6 +340,12 @@ fn items(source: &str) -> Vec<(syn::Item, SourceLocation)> { .expect("fixture parses") .items .into_iter() + .filter(|item| { + matches!( + item, + syn::Item::Fn(_) | syn::Item::Struct(_) | syn::Item::Enum(_) | syn::Item::Const(_) + ) + }) .map(|item| (item, loc.clone())) .collect() } @@ -294,12 +358,23 @@ fn ident(name: &str) -> syn::Ident { syn::parse_str(name).expect("ident parses") } +/// What one cell produced: the generator's answer, and — when it answered at +/// all — the Rust it emitted, which is what the next stage type-checks. +pub struct Outcome { + pub state: State, + /// The generated Rust. `Some` exactly when `state` is `PlanSupported`. + pub emitted: Option, +} + /// Run one cell, catching a panic as an outcome rather than letting it end the /// run. A generator that panics on an unsupported shape is reporting something /// — badly — and the table says so. -pub fn run(shape: &Shape, position: Position, target: Target) -> State { +pub fn run(shape: &Shape, position: Position, target: Target) -> Outcome { if let Some(reason) = not_applicable(shape, position) { - return State::NotApplicable(reason); + return Outcome { + state: State::NotApplicable(reason), + emitted: None, + }; } let source = fixture_source(shape, position); let decls = declarations(shape, position); @@ -310,9 +385,18 @@ pub fn run(shape: &Shape, position: Position, target: Target) -> State { })); match outcome { - Ok(Ok(())) => State::PlanSupported, - Ok(Err(msg)) => State::Rejected(msg), - Err(payload) => State::Panicked(panic_message(payload)), + Ok(Ok(emitted)) => Outcome { + state: State::PlanSupported, + emitted: Some(emitted), + }, + Ok(Err(msg)) => Outcome { + state: State::Rejected(msg), + emitted: None, + }, + Err(payload) => Outcome { + state: State::Panicked(panic_message(payload)), + emitted: None, + }, } } @@ -326,7 +410,20 @@ fn panic_message(payload: Box) -> String { } } -fn run_jni(source: &str, decls: &[Decl]) -> Result<(), String> { +/// The generated Rust, as the target wrote it. +/// +/// Through a file rather than a string because that is the only surface either +/// generator offers — a build script writes `generated_bindings.rs` and the +/// consumer `include!`s it, so this is also exactly what a consumer compiles. +fn read_back( + write: impl FnOnce(&std::path::Path) -> Result, +) -> Result { + let dir = tempfile::tempdir().map_err(|e| e.to_string())?; + let written = write(&dir.path().join("generated.rs"))?; + std::fs::read_to_string(written).map_err(|e| e.to_string()) +} + +fn run_jni(source: &str, decls: &[Decl]) -> Result { let mut pkg = prebindgen_jni::package!().fun(FunctionDecl::new(ident(PROBE_FN))); let mut error_decls: Vec = Vec::new(); for decl in decls { @@ -352,11 +449,7 @@ fn run_jni(source: &str, decls: &[Decl]) -> Result<(), String> { } let generation = builder.build().map_err(|e| e.to_string())?; - let dir = tempfile::tempdir().map_err(|e| e.to_string())?; - generation - .write_rust(dir.path().join("generated.rs")) - .map_err(|e| e.to_string())?; - Ok(()) + read_back(|path| generation.write_rust(path).map_err(|e| e.to_string())) } /// The declaration axis, spoken to the C builder. @@ -381,7 +474,7 @@ pub fn to_c(cbindgen: prebindgen_c::CbindgenBuilder, decl: &Decl) -> prebindgen_ } } -fn run_c(source: &str, decls: &[Decl]) -> Result<(), String> { +fn run_c(source: &str, decls: &[Decl]) -> Result { let mut cbindgen = Cbindgen::builder() .items(items(source)) .source_module(syn::parse_str(SOURCE_CRATE).expect("crate name is a path")) @@ -397,9 +490,5 @@ fn run_c(source: &str, decls: &[Decl]) -> Result<(), String> { } let generation = cbindgen.build().map_err(|e| e.to_string())?; - let dir = tempfile::tempdir().map_err(|e| e.to_string())?; - generation - .write_rust(dir.path().join("generated.rs")) - .map_err(|e| e.to_string())?; - Ok(()) + read_back(|path| generation.write_rust(path).map_err(|e| e.to_string())) } diff --git a/examples/shape-matrix/src/tests.rs b/examples/shape-matrix/src/tests.rs index 3043637f..a2c16382 100644 --- a/examples/shape-matrix/src/tests.rs +++ b/examples/shape-matrix/src/tests.rs @@ -127,15 +127,65 @@ fn a_cell_always_answers() { .find(|s| s.id == "scalar") .expect("scalar shape"); for target in Target::ALL { - let state = crate::run::run(shape, Position::Param, *target); + let outcome = crate::run::run(shape, Position::Param, *target); assert!( - matches!(state, crate::run::State::PlanSupported), - "a scalar parameter should cross for {}, got {state:?}", - target.as_str() + matches!(outcome.state, crate::run::State::PlanSupported), + "a scalar parameter should cross for {}, got {:?}", + target.as_str(), + outcome.state + ); + assert!( + outcome.emitted.is_some(), + "a cell that generated produced no Rust to check" ); } } +/// The compile check must attribute per cell, and must actually discriminate. +/// +/// It would be easy to write a check that marks everything as compiling: no +/// diagnostic ever matches, every cell passes, and the column becomes +/// decoration. So this feeds it one unit that compiles and one that cannot, in +/// the same crate, and requires it to separate them — which also pins the +/// attribution path, since the only thing linking a diagnostic to a cell is the +/// file rustc names. +#[test] +fn the_compile_check_separates_good_from_bad() { + let checked = check::check( + "selftest", + &[ + check::Unit { + id: "selftest_good__param__jni".to_string(), + fixture: "pub fn probe(v: u64) -> u64 { v }".to_string(), + emitted: "pub fn wrapper(v: u64) -> u64 { flat::probe(v) }".to_string(), + }, + check::Unit { + id: "selftest_bad__param__jni".to_string(), + fixture: "pub fn probe(v: u64) -> u64 { v }".to_string(), + // A type error in emitted code, of the kind a generator makes: the + // wrapper hands a string to a function taking an integer. + emitted: "pub fn wrapper() -> u64 { flat::probe(\"not a u64\") }".to_string(), + }, + ], + ) + .expect("the check runs"); + + assert!( + checked.compiled.contains("selftest_good__param__jni"), + "a unit that compiles was not recorded as compiling" + ); + assert!( + !checked.compiled.contains("selftest_bad__param__jni"), + "a unit that does not compile was recorded as compiling — the check is \ + reporting a state it did not establish" + ); + assert!( + checked.failed.contains_key("selftest_bad__param__jni"), + "the failing unit produced no attributed diagnostic, so nothing links a \ + compiler error to the cell it belongs to" + ); +} + /// The committed report is the regression gate; a stale one gates nothing. #[test] fn report_is_current() { From 70393c5c87a397e6bc91b494525dd0069b487206 Mon Sep 17 00:00:00 2001 From: Michael Ilyin Date: Thu, 13 Aug 2026 23:35:39 +0200 Subject: [PATCH 2/2] docs: step 2's rustc half has landed Splits the receipts step in two: rustc accepts the emitted Rust (done), and the rest of the toolchain plus the runtime states (not started). --- docs/shape-matrix.md | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/docs/shape-matrix.md b/docs/shape-matrix.md index 41fb4e2b..fd9383da 100644 --- a/docs/shape-matrix.md +++ b/docs/shape-matrix.md @@ -44,7 +44,8 @@ not a rewrite. | # | Step | State | |---|---|---| | 1 | The enumerator, both targets, committed report + regen gate | **landed** ([#400](https://github.com/milyin/prebindgen/pull/400)) | -| 2 | Receipts: `ToolchainCompiled` and `RuntimeExercised` | not started | +| 2 | Receipts: rustc accepts the emitted Rust | **landed** ([#403](https://github.com/milyin/prebindgen/pull/403)) | +| 2b | The rest of `ToolchainCompiled`, and `RuntimeExercised` | not started | | 3 | The minimum-guarantees table | not started | | 4 | Multi-parameter aliasing fixtures | not started | | 5 | The adapter-policy axis | JNI half ready; C half blocked | @@ -75,20 +76,29 @@ until new cells are classified" would be vacuous on its own — a regression tha flips a working cell to `rejected` would be recorded as a successful classification. -### 2. Receipts — the two states that need a toolchain +### 2. Receipts — rustc — landed -`plan` means generation succeeded. Nothing compiles, links or runs the result -yet, so the two strongest states are uncollected. +Every cell that produced Rust is compiled, and the state is a **receipt**: the +cell is written to its own file, the crate is checked in one pass, and each +diagnostic is attributed back by the file rustc names. Nothing maps a cell to a +fixture by hand — that mapping is what let #175's test pass without creating its +own precondition. -The rule from #198 stands: these states are **derived from mechanical receipts, -never declared**. A hand-written *cell → fixture-name* mapping can claim coverage -for a fixture that never touches the cell — the #175 failure exactly. A fixture -emits its cell id only *after* the relevant assertion has executed, and a cell -with no receipt stays `PlanSupported` whatever any table claims. +Compiler messages stay out of the committed report: they vary by toolchain, and +the report has to be identical on every one that builds it. -`examples/emitcheck` is the precedent for the compile half: it exists so rustc -judges emitted Rust. The runtime half rides the JVM covertest and the C smoke -tests. +Turning the compiler on immediately found ten cells whose generated Rust does not +compile, and three defects in this harness — each of which had been reporting a +confident wrong answer. That is the argument for this step in one sentence: +`plan` was worth less than it looked. + +### 2b. The rest of the toolchain + +`cbindgen` does not run, so a C cell that compiles has not been shown to produce +a valid header; the Kotlin compiler does not run either. `RuntimeExercised` needs +the JVM covertest and the C smoke tests, and the same receipt rule applies — a +fixture emits its cell id only *after* the relevant assertion has executed, and a +cell with no receipt keeps the weaker state whatever any table claims. ### 3. The minimum-guarantees table