Let a value form carry an Option<sum> field (#220) - #390
Conversation
An `Option<sum>` value-form field was refused by name, while the very same field on a `data_class` worked — the `fromParts` bridge emits a `<field>__present` flag beside its `<field>__tag`. Two paths, one supported the shape, one did not. Rejecting was the honest call when #213 landed: without gating, the emitter composes `match &(&__vf).result` on an `Option<Sum>` against bare variant patterns, which is E0308 in the consumer crate. A named error beat that. The issue proposes teaching core a present-flag notion on the output side. Core does not need one. The conditional-value-form work already built absence into the leaf model, and it covers this case as-is: `flatten` marks the field's last path step optional when something is decomposed below it, and ORs the field's own `opt` into every synthesized leaf's `nullable`. Everything downstream of that flag is already right — the tag boxes so JVM null cannot alias tag 0, `leaf_slot` knows each slot's default, and Kotlin puts a `null -> null` arm ahead of the real tags. What was missing is one function. `delivery.rs`'s sum-segment loop folded the selector's path with `fold_steps`, which has no optional handling — and said so in a comment ending "since an optional sum is refused where the leaves are built". A sum segment cannot take the per-leaf treatment `reach_leaf` gives an ordinary optional field: its leaves are not independent, only one group is live, so the whole segment gates as ONE tuple bind whose absent arm carries every slot's default. That is the shape a conditional hoist already emits, applied to an optional step inside the segment's own path. `encode_sum_group` is reused untouched. The optional step follows `reach_leaf`'s own division: a field step goes through `bind_as_option` so the destructure does not care how the source spelled the optional (#268); a call step keeps its direct match. `Vec<sum>` stays refused — variable arity has no fixed layout to lay out. covertest gains `Probe`, whose value form has an `Option<Lookup>` field. Its JVM harness pins the distinction the boxing exists for: an absent field is a null selector, while a PRESENT `Lookup.Absent` is tag 0 — a raw `jint` could not tell those apart. Kept off `Report`, which `Ledger` embeds twice, so the shape is not buried under signature churn. Generated output is additive only: 866 insertions, no existing declaration touched.
There was a problem hiding this comment.
Pull request overview
This PR removes the previous restriction that refused Option<sum> when used as a value-form field in the JNI generator, by teaching the Rust-side delivery emitter to gate an entire decomposed-sum segment as a unit when an optional step appears in that segment’s access path.
Changes:
- Update
encode_plan_leavessum-segment emission to detect an optional path step and wrap the whole sum segment in a singleOption::{Some,None}gate, defaulting all slots together in theNonearm. - Remove the declaration-time rejection for
Option<sum>value-form fields (while keepingVec<sum>rejected). - Add focused regression tests plus Kotlin/Rust covertest coverage via a new
Probehandle/value-form with anOption<Lookup>field, and document the output-path behavior.
Reviewed changes
Copilot reviewed 9 out of 11 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| prebindgen-jni/src/jni/tests/value_form.rs | Reworks the prior rejection test to keep only Vec<sum> rejection and adds new tests asserting gated vs ungated sum-segment emission for Option<sum> vs bare sum. |
| prebindgen-jni/src/jni/emit/delivery.rs | Implements optional-step detection in the sum-segment loop and emits a tuple-bind gate so Option<sum> defaults the entire segment together. |
| prebindgen-jni/src/jni/builder.rs | Removes the assert that rejected Option<sum> for value-form fields; keeps Vec<sum> refusal rationale. |
| examples/perftest-flat/src/ext.rs | Adds Probe and related APIs to exercise a value-form field outcome: Option<Lookup> across absent and present sum cases. |
| examples/covertest-kotlin/src/generated_bindings.rs | Regenerates Rust JNI bindings to include Probe handle support and callback/builder delivery for the new value-form shape. |
| examples/covertest-kotlin/kotlin/REPORT.md | Updates the generated Kotlin report with probe_each/probe_new and the decomposed leaf shape. |
| examples/covertest-kotlin/kotlin/io/prebindgen/covertest/Test.kt | Adds a runtime Kotlin section that distinguishes “absent sum” (null) from Lookup.Absent (tag 0) to validate selector boxing semantics. |
| examples/covertest-kotlin/kotlin/generated/io/prebindgen/covertest/model.kt | Regenerates Kotlin model to include Probe, its callbacks/builders, and nullable selector mapping for the optional sum field. |
| examples/covertest-kotlin/kotlin/generated/io/prebindgen/covertest.kt | Regenerates Kotlin native interface declarations for probeEach/probeNew. |
| examples/covertest-kotlin/build.rs | Extends covertest binding generation inputs to include Probe and its value-form expansion. |
| docs/sum-types.md | Documents that Option<sum> is supported in both output paths (fromParts present-flag vs value-form selector nullability) and that Vec<sum> remains refused. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
The only conflict is generated: covertest's model.kt, where this branch's `Probe` handle class lands next to #218's `Verdict` cascade. Both belong in the output, so the file is not hand-merged — it is regenerated, and every other generated file with it (`covertest.kt`, the two `generated_bindings.rs`, and the new `emitcheck` one from #382).
| .join("\n"); | ||
|
|
||
| assert!( | ||
| !rust.contains("::core::option::Option::None => ("), |
There was a problem hiding this comment.
This negative assertion is vacuous — the test cannot fail.
prettyplease renders a tuple-valued match arm as ::core::option::Option::None => { + newline + (, never None => ( on one line. I verified this empirically: dropping this exact assertion into an_optional_sum_field_gates_its_whole_segment (which does emit the gate) makes it pass there too.
So the dual test the PR advertises as pinning "the optional path isn't entered when there's nothing to gate" would stay green even if the gate were emitted for a bare sum field. Only the !kotlin.contains("null -> null") check below carries weight.
The sibling test already gets this right by asserting on the whitespace-stripped text (rc); this one should do the same.
| !rust.contains("::core::option::Option::None => ("), | |
| !rust.split_whitespace().collect::<String>().contains("::core::option::Option::None=>{("), |
There was a problem hiding this comment.
Fixed in 7925993, with the whitespace-stripped form.
One thing worth recording, because it changes what the test is worth: the assertion is now able to fail — the needle ::core::option::Option::None=>{( is exactly what the sibling test asserts is present in the gated emission — but I could not construct a mutation that makes it fail in this fixture. Forcing the predicate (find(|_| true)) leaves the test green, because for a bare sum field project_leading_fields consumes the result step as a plain field, so lead == path.len() and the search range is empty before the predicate is ever consulted.
So the dual’s real guard against the gate firing is structural rather than asserted, and only the Kotlin null -> null check is falsifiable by touching the gate. The fix is still worth having — it is now a claim about the output instead of a claim about prettyplease’s line breaking — but it should not be read as pinning more than it does.
| // every slot's default, which is the shape a conditional value form's | ||
| // hoist already emits below; this applies it to an optional step inside | ||
| // the segment's own path. | ||
| let opt_at = (lead..path.len()).find(|&i| path[i].is_optional()); |
There was a problem hiding this comment.
Stale comment directly above this line now contradicts the code (lines 951-953, just outside the hunk):
// The value to
matchon. The selector's own path reaches the sum
// (empty when the sum IS the value); no step on it is optional, since
// an optional sum is refused where the leaves are built.
That is exactly the invariant this PR deletes in builder.rs, and the very next statement (let opt_at = …) exists because a step on it can now be optional. In a codebase where these comments are the primary record of why the emitter is shaped the way it is, leaving this one in place will send the next reader looking for a refusal that is gone.
There was a problem hiding this comment.
Fixed in 7925993. It now reads:
The value to
matchon. The selector's own path reaches the sum (empty when the sum IS the value), and a step on it MAY be optional: the refusal that used to guarantee otherwise is gone (#220), which is what the gate below exists for.
Agreed on the reasoning — a comment asserting a refusal that the same PR deletes is worse than no comment, because it reads as a reason not to look.
| // statement of it. | ||
| let opt_e = fold_steps(&qualify, &path[lead..=k], projected, false); | ||
| let bind = format_ident!("__sg{}", seg.start); | ||
| let inner = fold_steps(&qualify, &path[k + 1..], quote!(#bind), true); |
There was a problem hiding this comment.
Two things about the tail after the gated step, both latent today but worth stating since the removed builder.rs assert was the only named diagnostic in this area:
-
owned = truedisagrees withreach_leaf. In the FIELD branch below,#bindcomes from matching a&::core::option::Option<_>, so match ergonomics binds it as&Sum— a borrow, not an owned value.reach_leafpassesfalsefor its analogous recursion (reach_leaf(.., quote!(#nested), /*base_is_ref=*/true, ..)→fold_steps(.., false)). Withtrue, aCallstep inpath[k + 1..]would be composed as&(&Sum)and hand the accessor&&Sum→ E0308 in the consumer crate.trueis only right for the CALL branch (ownedOption<T>scrutinee); one flag is serving two branches that disagree. -
opt_atfinds only the first optional step, andfold_stepshas no optional handling, so a second optional step in the tail would composematch &Option<..>against bare variant patterns — the sameE0308the deleted assert was written to pre-empt, now with no named error.
Both are unreachable right now only because synth_sum_leaves gives every sum leaf an empty path, so path[k + 1..] is always empty. That invariant isn't stated anywhere here. A debug_assert!(path[k + 1..].is_empty(), ..) (or just passing path[k].yields_owned() instead of the literal true) would keep this honest.
There was a problem hiding this comment.
Both taken in 7925993, and your framing of (1) is better than mine — I had flagged the same flag as "inert today", but reach_leaf passing false for the analogous recursion is the argument that settles it: two branches were sharing a literal that only one of them makes true.
Fixed as path[k].yields_owned() rather than a branch on path[k].is_field(). yields_owned is Call { owned: true } and nothing else, so it answers correctly for both arms from the step itself — the FIELD arm gets false (the &Option<_> scrutinee binds &Sum) and an owned-yielding CALL gets true — and there is no second place stating which arm is which.
For (2) I took the debug_assert!(path[k + 1..].is_empty(), ..) over my own suggestion of asserting no second optional step: emptiness is the invariant that actually holds and it subsumes the other. It names the E0308 the deleted builder.rs assert used to pre-empt, so the diagnostic that left builder.rs has a home. 253 lib tests, both Kotlin examples and emitcheck run without it firing.
Independently of the reachability question, I probed the shape that would put a second optional step in one path — Option<Child> where Child's own value form holds Option<sum> — through emitcheck. It is refused upstream by name ("a value form nested under another one that is reached through Option — conditional hoists do not nest"), so the assert documents an invariant that two separate mechanisms currently keep.
ReviewMerged The core argument holds up, and it is the good one: #220 asks for a present flag and the answer is that the conditional-value-form work already paid for absence, so the diff is one function and no core, no Verified locally: What I went after, and what it foundThe one thing that worried me is that the gate handles the first optional step and folds the remainder with Three routes, all resolved:
So the gap I was looking for is closed, and Two notes, both "say it", not "fix it"1. The remainder's triviality is load-bearing and unstated. 2. Neither blocks. Both are about the same thing: this function now has an invariant it does not state. Smaller things
The mergeOne conflict, entirely generated: |
Three findings from the review, plus the flag one of them names.
The comment above the segment loop still said "no step on it is
optional, since an optional sum is refused where the leaves are built" —
the invariant this PR deletes, sitting two lines above the code that
exists because it is gone. It now says a step MAY be optional and points
at the gate.
`fold_steps(.., true)` for the tail asked nobody: the FIELD branch
scrutinizes `&Option<_>` (that is what `bind_as_option` is for), so match
ergonomics binds `&Sum`, a borrow — `reach_leaf` passes `false` for its
analogous recursion. `path[k].yields_owned()` answers for both branches
from the step itself, which is `Call { owned: true }` and nothing else.
The tail's emptiness is what makes the rest safe — a second optional step
would compose `match &Option<..>` against bare variant patterns, the
E0308 the deleted `builder.rs` assert used to pre-empt by name. A sum
leaf's path stops at the sum, so it holds; it is a `debug_assert!` now
rather than an unstated fact, and 253 tests plus both Kotlin examples
run without it firing.
`a_bare_sum_field_takes_no_gate`'s negative assertion could not fail:
`prettyplease` renders a tuple-valued arm as `None => {` + newline + `(`,
so `None => (` appears in neither emission. Asserting on the
whitespace-stripped text, as the sibling test already does, makes it a
claim about the output rather than about the formatter — the needle it
looks for is exactly what the gated test asserts is present.
|
@copilot resolve the merge conflicts in this pull request |
| debug_assert!( | ||
| path[k + 1..].is_empty(), | ||
| "sum segment: a step after the gated one ({} left) — the tail \ | ||
| is assumed empty here", | ||
| path.len() - k - 1, | ||
| ); |
There was a problem hiding this comment.
Both halves taken, in b2b3e0c.
assert!, not debug_assert! — and the argument is stronger than "invariants should be enforced": a build script inherits the consumer's profile, so debug_assert! is absent from exactly the release build of a downstream crate where a mis-emission is most expensive to diagnose. That defeats the reason I gave for adding it — the deleted builder.rs assert was the only named diagnostic here, and a debug-only replacement hands back a bare E0308 in generated code the author did not write. It now matches reach_leaf's own optional-step assert a few hundred lines up, message shape included.
The condition — agreed, with the reasoning worth stating because it cuts against my own commit message. Emptiness is true today (a sum leaf's path stops at the sum) but it is not the safety condition: a non-optional tail composes correctly through fold_steps, the more so now that the fold takes path[k].yields_owned() rather than a literal. Asserting emptiness would refuse a shape that works. A second optional step is the one that composes match &Option<..> against bare variant patterns, so that is what is asserted:
assert!(
!path[k + 1..].iter().any(PathStep::is_optional),
"jnigen unfold: leaf `{}` reaches its sum through TWO optional steps — the \
segment gate has one `None` arm, so the second would be matched as if it \
were the sum itself",
leaf.name,
);For the record on reachability: the shape that would trip it — Option<Child> where Child's own value form holds Option<sum> — is refused upstream by name ("a value form nested under another one that is reached through Option — conditional hoists do not nest"), measured through emitcheck. So this assert is a second lock on a door that is already shut, which is the right weight for it — not a load-bearing guard, but the named error if the first lock ever opens.
255 lib tests, regen-check.sh byte-identical, clippy -D warnings + fmt --check on 1.85.0 and stable.
Co-authored-by: milyin <1909657+milyin@users.noreply.github.com>
Resolved by merging |
Two corrections to 7925993, both from the Copilot review. `debug_assert!` put the check in the one build where it is least affordable: a build script inherits the consumer's profile, so a release build of a downstream crate runs the generator with assertions off — and a mis-emission there costs a bare E0308 in generated code the author did not write, which is exactly the diagnostic the deleted `builder.rs` assert used to give by name. `assert!` now, matching `reach_leaf`'s own optional-step assert a few hundred lines up, message shape included. And it asserts what breaks rather than what happens to be true. Every sum leaf's path stops AT the sum, so the tail is empty today, but emptiness is not the safety condition: a non-optional tail composes correctly through `fold_steps` — more so since the fold takes `path[k].yields_owned()` — so an emptiness assert would refuse a shape that works. A SECOND optional step is the one that would compose `match &Option<..>` against bare variant patterns, and that is the condition now.
Closes #220.
The asymmetry
Option<sum>was refused as a value-form field while the same field on a data class worked — thefromPartsbridge emits a<field>__presentflag beside its<field>__tag(PlanFieldKind::Sum { optional }). Same shape, one position over, different answer.Rejecting was right when #213 landed: without gating, the emitter composes
match &(&__vf).resulton anOption<Sum>against bare variant patterns —E0308in the consumer crate. A named error beat emitting that.The issue's premise is out of date
#220 proposes teaching core a present-flag notion on the output side, dual to
FlatLeaf::is_present_flag. Core does not need one. The conditional value form work already built absence into the leaf model, and it covers this case unchanged —prebindgen-registry/src/unfold.rs::flattentoday already:PathStep::field(last, opt && decomposed), anddecomposedis true for a sum (FieldDecon::Leaves);optinto every synthesized leaf'snullable.And everything downstream of that flag is already correct: the tag boxes so JVM
nullcannot alias tag0(leaf_is_prim),leaf_slotknows each slot's wire default,iface.rstypes the selectorInt?and the group nullable, andkotlin_emit.rsputsnull -> nullahead of the real tags. All of it JVM-tested today throughledgerEach.So this PR changes no core, no
iface.rs, nokotlin_emit.rs.The one function
delivery.rs's sum-segment loop folded the selector's path withfold_steps, which has no optional handling — and said so:A sum segment can't take the per-leaf treatment
reach_leafgives an ordinary optional field: its leaves aren't independent, only one group is live. So the whole segment gates as one tuple bind whose absent arm carries every slot's default — the shape a conditional hoist already emits, applied to an optional step inside the segment's own path:encode_sum_groupis reused untouched. The optional step followsreach_leaf's own division: a field step goes throughbind_as_optionso the destructure doesn't care how the source spelled the optional (#268); a call step keeps its direct match, since an owned position can't be coerced that way.Vec<sum>stays refused — variable arity genuinely has no fixed layout.Coverage
a_sum_field_behind_option_or_vec_is_rejected_by_name→a_vec_sum_field_is_rejected_by_name; theOptionrow and its rationale are gone.New
an_optional_sum_field_gates_its_whole_segment— the coercion site, the tuple-bound absent arm, the tag defaulting to JVM null rather than a rawjint, and Kotlin'snull -> null.New
a_bare_sum_field_takes_no_gate— the dual, so the optional path isn't entered when there's nothing to gate.examples/covertest-kotlingainsProbe, whose value form has anOption<Lookup>field. The JVM harness pins the distinction the boxing exists for:A raw
jintselector could not tell those apart, which is the whole reason no present flag is needed. 50 sections pass under./gradlew run.Deliberately kept off
ReportStruct:Reportis embedded twice inLedger, so a field there would add 3 positional slots toreportEachand 6 toledgerEachand bury the change under signature churn.Verified
cargo test --all; clippy-D warningsandcargo fmt --check(CI's exact config) on 1.85.0 and stable;examples/regen-check.shclean. Generated output is additive only — 866 insertions, no deletions, no existing declaration touched — andregen-check.sh --with-zenoh-flat-jniis byte-identical, since zenoh-flat'sReplyStruct.resultis a bareReplyResult.docs/sum-types.md§4.3 now records that both output paths gateOption<sum>, by two different means — a present flag on thefromPartsbridge, the selector's own nullability in a leaf list.