Skip to content

Function type support - #8

Merged
milyin merged 3 commits into
mainfrom
function_type_support
Jul 27, 2025
Merged

Function type support#8
milyin merged 3 commits into
mainfrom
function_type_support

Conversation

@milyin

@milyin milyin commented Jul 27, 2025

Copy link
Copy Markdown
Owner

No description provided.

@milyin
milyin merged commit 45f1512 into main Jul 27, 2025
milyin added a commit that referenced this pull request Jul 28, 2026
… extents to C (#210, first step of #211) (#212)

* Lower array lengths in one frontend walk, not two adapter walks

Array-length handling had two independent walks over the same expression: a
whitelist in jnigen deciding what was ACCEPTED, and a rewriter deciding what it
could QUALIFY. Nothing tied them together, so they drifted eight times (#210).
The eighth was still open — the whitelist accepted every `Expr::Path`, the
rewriter skipped paths with a `qself`, so

    pub qualified_assoc: [u8; <Holder>::N],

was accepted and emitted verbatim into a crate where `Holder` is not in scope.
Fixing that shape alone leaves the mechanism that produced #1-#8 intact.

Both walks are replaced by one fallible lowering in a new `core::frontend`:

    lower_array_len(&Expr, &NameIndex) -> Result<ArrayLen, UnsupportedArrayLen>

with the contract that `Ok` means the length was fully understood AND fully
resolved. "Accepted" is now a consequence of lowering rather than a parallel
judgement, so accepted-but-unqualified is not representable. `ArrayLen` is the
one closed representation every consumer reads.

It runs at INGEST, as pass 3 of `Registry::from_items`, where the name index is
complete. So the decision is made before any adapter exists — both generators
refuse the same input with the same message — and no emit-time length pass
remains: `reject_unsupported_array_length`, `QualifyLengthPaths` and the
`length_names` map are deleted, along with the comment block recording which
defect each guard came from.

The grammar narrows to a literal or a plain path. Const arithmetic
(`[u8; A + 1]`, `[u8; A as usize]`) and `const fn` calls (`[u8; array_len()]`)
leave the language; hoist the value into a named `const`. This is an
intentional breaking change with a small blast radius — `[T; N]` crossing
arrived in #209 and no released binding depends on it. `zenoh-flat`'s
`[u8; ZENOH_ID_MAX_SIZE]`, the one real use, is a free-const path and is
unaffected; regenerating zenoh-flat-jni changes converter names and one
diagnostic string, nothing on the Kotlin surface.

Three variants rather than the two #211 sketches: a path naming nothing the
registry indexes (`usize::MAX`) is legal and must be emitted verbatim. That was
a silent fallthrough in the old rewriter; `ExternalConst` makes it explicit, so
lowering stays total instead of reintroducing a guess.

Two shapes change behavior beyond the narrowing. A `qself` is now an explicit
rejection naming the offending sub-expression — #210 asked for the decision to
be made either way, and silent acceptance was the one option it ruled out. And
`crate::MAX` now resolves like the bare `MAX`; previously its leading segment
missed the name set and it was emitted as `crate::MAX`, which names the
CONSUMER's crate. Stripping the source head is deliberately not the type-path
rule, which reduces to the final segment and would collapse
`myflat::Holder::N` to `N`, losing the owner of the associated const.

Tests replace the eight characterization tests with a table: accepted spelling
-> lowered value -> emitted spelling, and refused spelling -> reason. A new
shape is a row. The two jnigen tests that only an end-to-end run can prove
survive — that qualification reaches emitted code and does NOT touch a
converter body's identically-named locals.

The covertest fixture sizes `Arrays::bytes` by a `#[prebindgen]` const instead
of a literal. That the covertest crate COMPILES is the check: a literal would
prove nothing about resolving a path a different crate can name.

`docs/source-language.md` writes down the accepted subset — #211 step 1 — and
is explicit that only this row has moved into the frontend so far.

Closes #210. First step of #211.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Resolve a length path by its item, not by its first segment

Review finding on #212. `strip_source_head` dropped a `crate`/`self`/source-crate
head and then assumed the next segment was the semantic anchor. That assumption
holds only if accepted paths contain no intermediate modules, which was never
stated and is not true:

    mod limits { #[prebindgen] pub const MAX: usize = 4; }
    pub bytes: [u8; crate::limits::MAX],

`limits` is not indexed, so this became `ExternalConst { limits::MAX }` and the
generated crate emitted an unqualified `limits::MAX` — unresolvable there, or
worse, bound to a consumer-side module. It also contradicted the `ExternalConst`
contract outright: the head was stripped BEFORE the path was classified, so a
path documented as verbatim was not.

The fix is the flat namespace. Prebindgen items are uniquely named and reachable
as `<origin crate>::<bare name>`, so a module prefix inside the source crate
carries no information — the bare name already identifies the item. Lowering
becomes two ordered decisions:

1. Is the path source-relative? Its head is `crate`, `self`, a source module, or
   an indexed name. Everything else is external and is returned untouched.
   Classifying FIRST is what makes the verbatim guarantee true; it also keeps
   `other_crate::Holder::N` alone even though `Holder` names an indexed item,
   the same rule `normalize_type` applies to foreign type paths.
2. Which segment is the item? The leftmost that names one. Everything before it
   is module path and is replaced by the origin module; everything after is
   relative to the item and kept.

So `MAX`, `crate::MAX`, `myflat::MAX`, `crate::limits::MAX` all lower to one
value — which is the property that was missing, not just a missing branch.

Leftmost, not rightmost: in `Holder::N` with a free const `N` also indexed,
`Holder` is the anchor and `N` is its associated const. A `Leaf` item — a const
or fn, through which nothing is reachable — is skipped when segments follow it,
so a const sharing a module's name cannot capture the path. That is the only
collision Rust permits, modules and types sharing one namespace but consts
sitting in another, and it is a matrix row.

A source-relative path naming no indexed item is now a hard error rather than
silently verbatim: it claims a source item, so emitting it into a different
crate is exactly the misinterpretation this pass exists to prevent. The bare
spelling stays `ExternalConst` — indistinguishable from an external namespace —
and the docs now say so instead of implying one rule.

This makes explicit an invariant that was already load-bearing for TYPES:
`normalize_type` reduces `crate::a::Foo` to `Foo` and emission qualifies it to
`myflat::Foo`, so a nested item has always had to be re-exported at the source
crate root. zenoh-flat already does — `ZENOH_ID_MAX_SIZE` lives in
`base::config::zenoh_id` and `lib.rs` re-exports it.

Verified the anchor scan is load-bearing by restoring the first-segment rule:
the matrix fails. Generated output byte-identical; covertest PASS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* An array length is a number, not a path

Rereview of #212 found a two-source provenance hole: one registry-wide name
index, no origin for the item being lowered, so `crate::limits::MAX` in source A
— with `MAX` unmarked there and marked in source B — anchored to B and silently
changed the length from 4 to 8. It also found relative module paths to be
irreducibly ambiguous without indexing modules, and the constructor unusable
through the public facade.

All three dissolve rather than get patched, once the requirement is stated
properly: a length must be a KNOWN NUMBER. A generator runs in build.rs and
cannot evaluate Rust, and a destination language that groups a small array into
scalars needs the count literally — a Kotlin surface cannot reference a Rust
const at all. So the grammar is an integer literal, or the BARE NAME of a
`#[prebindgen]` const whose own initializer is an integer literal. The frontend
reads the value and emits the number.

That is a real narrowing, and it is the point:

* module paths go, because a marked item is uniquely named and the bare name is
  its whole address — `crate::limits::MAX` only restated it, and `limits::MAX`
  was never distinguishable from a foreign crate path;
* associated consts go, because prebindgen never captures `impl` blocks, so
  `Holder::N` could only ever have produced a path, never a number;
* external paths go, and `usize::MAX` shows why — it is not even a fixed value;
* an UNMARKED const is now a hard error rather than emitted verbatim. The
  generated crate sees only what the macro exposed, so this was never a
  qualification problem: the item does not exist downstream.

Provenance is then a one-line rule instead of a lowering context: a bare name
must be a const marked in the item's own source crate. Uniqueness holds across
the marked namespace only, so without it the review's example still binds to the
other crate's value; with it, that is unrepresentable. Origin-less streams are
one anonymous crate and match trivially.

Emitting the number is what removes the defect class rather than fixing it.
There is no path left in generated code, so there is nothing to qualify, no
namespace to get wrong, and no re-export invariant to rely on. A const length
and the same number written literally become one type and one converter — which
they always were in Rust. And a changed const now shows in the diff of a
committed generated artifact, where echoing the name showed nothing at all.

API surface (finding 3, my call): the lowering machinery is crate-private and
the public frontend is the decided model plus its diagnostics. `NameIndex`,
`ItemRole` and `ArrayLenResolver` are gone from the facade rather than
completed; the entry point is `Registry::from_items`, and offering a second
route to a partly-built model is what #211 exists to prevent.
`Registry::named_item_idents`/`named_items` go with them — they existed only for
the machinery this replaces.

The restriction is on LENGTHS, not on consts: a `#[prebindgen]` const may still
be computed however you like.

Generated output is byte-identical to main apart from the new fixture const:
`[u8; ARRAY_BYTES]` evaluates to the `[u8; 4]` that was there before. covertest
PASS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* A type-keyed length table stores the number, not the spelling

Review finding on 4880ffe. `Registry::array_lens` was keyed by `TypeKey` but
stored `ArrayLen`, which records WHICH SPELLING produced it — and `TypeKey` has
already collapsed the spellings. Given

    pub a: [u8; A],        // A = 4
    pub b: [u8; B],        // B = 4
    pub literal: [u8; 4],

all three are one key, so the stored `Const { name }` was whichever occurrence
the iteration reached last: order dependent, and false for the other two either
way. No consumer reads the name today, so nothing drifted — it was a latent
nondeterminism and a claim the table could not honour.

Three identities were being conflated, and the fix is to keep them apart rather
than to drop the resolution:

* const identity — `A` is `4` in crate X — the const index;
* type semantics — `[u8; 4]` — the type table;
* source-use provenance — field `S::a` was written `[u8; A]` — per occurrence,
  belonging to a per-use model that does not exist yet (#211's `SourceModel`).

So the type-keyed table holds `usize` and `Registry::array_len` returns one.
`ArrayLen` keeps the spelling, because at the occurrence level that is true, and
becomes crate-private: there is no type-keyed table it could be handed out
through honestly, and nothing public produced it any more.

`equal_lengths_collapse_to_one_typed_entry` is the regression: three fields, two
const-spelled with equal values, one type between them. It asserts both halves —
the occurrences stay distinguishable, the type does not — so the layering is
pinned rather than the current storage choice.

Generated output byte-identical.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* The spelling of a length is discarded, and says so

Review finding on 961be86: the previous test claimed occurrences "stay
distinguishable", but that was only true inside the lowering call. After
`Registry::from_items` the item types are rewritten to the literal and the
occurrence vector is dropped, so no adapter can tell that `S::a` wrote `A`,
`S::b` wrote `B`, and `S::literal` wrote `4`. The model was advertising
provenance that did not survive the boundary it was documented at.

The review offered two coherent ends: build the per-use record now, or declare
the spelling semantically irrelevant. Taking the second, explicitly.

`ArrayLen` is deleted; lowering returns `usize`. It carried a name that nothing
read — a successful lowering raises no diagnostic, and `UnsupportedArrayLen`
already renders the offending expression — so the enum existed only to make the
overclaim expressible.

Why the policy rather than the record: `[u8; A]` and `[u8; B]` with `A == B ==
4` are one Rust type, one `TypeKey` and one converter. An adapter rendering them
differently would need two destination representations for a single Rust type,
which the type-keyed converter table cannot express — so the capability is
incoherent at this layer even if the provenance were carried. Building a per-use
record now would also mean inventing the "stable use identity" that is precisely
`SourceModel`'s design work, one construct at a time, which is the parallel
structure #211 exists to remove.

The cost is stated rather than hidden: a C header cannot echo
`uint8_t x[MAX_SIZE]`, only `uint8_t x[16]`. If that is wanted, provenance
arrives on the use site in `SourceModel` — never on a type-keyed table, where
three occupants of one key cannot be told apart by the key.

The regression now inspects the state AFTER `Registry::from_items`, as asked,
and pins both halves: all three fields are `[u8; 4]` sharing one type-keyed row
with value 4, and the const names appear nowhere in the model.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* A C header can spell an array extent by name

`uint8_t tag[MARKER_TAG_LEN]`, not `uint8_t tag[4]`. A symbolic extent is part
of the C API's meaning: it makes changing the size one edit instead of a hunt
through literals. 32370af had discarded the spelling, so that fact could not
reach cbindgen at all.

The obvious patch — stash the original `syn::Type` and let cbindgen re-read it —
is what #211 forbids twice over: adapters do not parse captured source, and
emitters do not recover semantic facts by re-reading it. So the fact goes in the
IR, and this begins `SourceModel` (#211 step 2) rather than building a side
channel that would have to be deleted again.

`core::frontend::model` adds a closed, language-neutral `SourceType`. Lowering
is TOTAL over the grammar in docs/source-language.md — a form it cannot lower is
a frontend error, the same acceptance-is-lowering contract array lengths already
had. An array node carries `ArrayExtent { value, source }`: the value is the
semantic length that keeps `[u8; A]` and `[u8; 4]` one type and one converter,
the source is which const the use site named. Both halves have consumers, and
they are different ones.

The model is the source of truth and `syn` is a projection: pass 3 lowers every
struct field and writes `to_syn()` back into the `syn::ItemStruct`, so there are
never two representations to disagree. `TypeKey` stays numeric, which is why all
of jnigen and most of cbindgen are untouched — covertest PASS, generated Kotlin
and Rust byte-identical.

Scope is bounded on purpose. Types: complete. Items: structs only, the one
surface an adapter consumes as source; functions and enums keep their `syn`
items, which #211 step 4 sanctions while adapters migrate. Adapters: cbindgen's
struct-field path only. Its per-field CLASSIFICATION still runs on `to_syn()` —
`SourceType` is already the answer to `is_scalar`/`is_string`/`is_vec`, but
deleting those duplicates is F5/F6, not this change.

Two things blocked the header besides the missing fact, both verified rather
than assumed:

* cbindgen had no array support at all, so `[T; N]` in a data-struct field
  panicked. `c_field_wire` now accepts an array of non-`bool` scalars: `[u8; N]`
  is already `#[repr(C)]`-compatible, so it is its own wire and the field copies
  with no conversion. `[bool; N]` stays refused — its domain is `0`/`1` and a
  mirror is reinterpreted wholesale with no per-element hook, the same hazard
  `restricted_validity_field` documents.
* consts never reached the header. `on_const` aliased them to
  `= perftest_flat::ARRAY_BYTES`, a path cbindgen cannot evaluate, so it emitted
  no `#define`. A probe pinned this down exactly: with a literal it emits
  `#define MARKER_TAG_LEN 4` and `uint8_t tag[MARKER_TAG_LEN]`; with the alias
  it emits the struct referencing an UNDEFINED symbol. So the literal is not
  cosmetic — without it the feature produces a header that does not compile.
  Only extent consts change; the rest keep the alias.

The exercise is in example-cbindgen because `smoke-asan.sh` is the only place CI
compiles a generated header. `Marker { tag: [u8; MARKER_TAG_LEN], weight }`
round-trips by value in smoke.c, which uses the macro as a bound — so the test
COMPILING is most of the proof, since a missing `#define` is a compile error.

The x86_64 goldens are derived: the substitution from the aarch64 pair was first
verified to reproduce the committed x86_64 pair exactly at HEAD, then applied.
CI on x86_64 is the oracle.

`equal_lengths_collapse_to_one_type_after_ingest` now pins both halves for real.
The second half — that the model still reports `A`, `B` and a literal per field
— was the assertion the previous review correctly called untrue.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* The projection preserves type identity

Review finding on 21ab04a. Pass 3 writes `field.ty = ty.to_syn()`, which made
the projection a semantic rewrite in the live pipeline — while it could not
reconstruct five forms the grammar accepts:

    &'static Foo         -> &Foo
    Foo<'static>         -> Foo
    (Foo,)               -> (Foo)
    <T as Trait>::Assoc  -> Trait::Assoc
    foreign::Option<u8>  -> Option<u8>

Each changes `TypeKey`, so each changes converter selection and the emitted
field type. The lifetime losses contradict this repo's own rule that
`Foo<'static>` is not `Foo`, which `ptr_class!(ZKeyExpr<'static>)` relies on.

Three of the five turn out to be LANGUAGE questions, so the fix narrows the
grammar rather than growing the model:

* Tuples are not in the language. Verified: every `Type::Tuple` site in both
  adapters is the unit case or a generic walk — no adapter has ever lowered a
  non-empty tuple. Refusing deletes the 1-tuple bug instead of fixing it, and
  turns a late "unresolved type" into a precise frontend error naming the type.
* Associated types are refused. `#[prebindgen]` never captures `impl` blocks, so
  what `<T as Trait>::Assoc` resolves to is unknowable here; carrying the
  spelling would only move the failure downstream. `Named` then needs no
  `qself`, which removes that reconstruction path entirely.
* `foreign::Option` was a DETECTION bug, not a fidelity one: builtins matched on
  the last segment alone. They now require a bare single-segment path, which is
  exactly what `normalize_type` already guarantees — it reduces the genuine std
  spellings at ingest and deliberately leaves unknown crate paths alone. A
  foreign type that merely shares a name stays foreign.

The remaining two are fidelity, and lifetimes are kept the way they actually
behave: part of a type's NAME, never structure, because they mean nothing to a
destination language. `Named` keeps its path as identity with the last segment's
arguments split out in SOURCE ORDER, lifetimes included, so `Foo<'a, T>` comes
back exactly; nested types stay canonical, so an extent inside a generic still
projects numerically and `TypeKey` stays right. `Ref` gains a lifetime — no
struct field needs one today, but `&'static Encoding` is a real zenoh-flat
return, and the variant has to be right before signatures are modeled.

The old `to_syn_round_trips` could not catch any of this: it projected an
already-projected type, so it only proved a lossy function idempotent. It is
replaced by comparing `TypeKey` of the NORMALIZED ORIGINAL against `TypeKey` of
its projection, over every accepted form including all five above. Verified it
discriminates by restoring two of the bugs: each fails naming its own symptom.

Generated output is byte-identical in-repo, and the sibling zenoh-flat-jni
regenerates identically to 21ab04a — none of the five occurs in a real struct
field, so a correct fix moves nothing. covertest PASS, smoke.c PASS.

Also fixes the contract text the review flagged: `frontend.rs`'s "Scope today"
and `docs/source-language.md`'s "Read this first" both still claimed only array
lengths had moved. The Types table now states the tuple and qself refusals, and
records that they bind only in MODELED positions — struct fields today — which
is the honest shape of a partial migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Turbofish and a trailing generic comma are not identity

Review finding on 13978e7: `Wrapper::<u8>` and `Wrapper<u8,>` lost their
punctuation through `to_syn()`, and `TypeKey` — whose identity is the normalized
token string — treated the result as a different type.

The damage is worse than a mismatched key. Modeled struct fields are rewritten
through `to_syn()` and function signatures are not, so during the migration one
Rust type would hold the old key in a signature and the new key in a field,
splitting converter lookup by POSITION.

Fixed where the reviewer said it belongs: at the declared canonicalization
boundary. `normalize_type` now drops the turbofish and a trailing comma, so
every position agrees and no consumer has to. Carrying the punctuation in
`SourceType` was the alternative, and it is the wrong one — the spellings mean
the same type, so preserving the difference only spreads it.

That also keeps reconstruction from silently defining canonicalization, which
was the reviewer's real point: `to_syn()` already emitted the bare form, so it
was deciding by accident what `normalize_type` should decide on purpose.

Verified both tests are load-bearing by removing the normalization: the
types_util test fails `"Wrapper :: < u8 >" != "Wrapper < u8 >"` and the identity
matrix fails `Wrapper::<u8> lost identity`, which is exactly what the review
reproduced.

Lifetimes stay identity — `Foo::<'static,>` collapses to `Foo<'static>`, never
to `Foo`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Close the callback gate, and say which refusals are temporary

Review finding on a3cf2af, and this one was LIVE rather than latent.
`extract_fn_trait_args` is the acceptance gate for every callback — 14 call
sites across core resolution and both adapters — and it read `p.inputs` while
never looking at `p.output` or the bound's `lifetimes`. So

    impl Fn(u8) -> u16 + Send + Sync + 'static

was accepted, planned from its arguments alone, and its result dropped: every
callback wire is void-shaped, C's `call` having no return and jnigen's `run`
returning void. The test that now refuses it reports, without the fix, exactly
what was happening — `accepted as Callback { args: [Scalar(U8)] }`.

Refusals now come in two kinds, because a bare "no" leaves the author guessing
whether to redesign or to wait, and those are opposite responses:

* RESERVED — the language intends it, the machinery does not exist yet. A
  callback returning a value is reserved, and the diagnostic names issue #216,
  which carries the design that actually blocks it: both adapters have a case
  where the foreign side yields no value (a null `call` pointer, a throwing
  Kotlin callback), and with a return type there is nothing to swallow to.
  A callback returning a callback is reserved too.
* UNSUPPORTED — it cannot work here. A higher-ranked binder is the case: no FFI
  boundary can be generic over a lifetime, so no adapter could ever carry one.

The gate becomes `extract_fn_trait_sig -> Result<_, CallbackReject>` with
`extract_fn_trait_args` a thin `.ok()` wrapper, so the 13 shape-query callers
are untouched and ONE function still decides. A separate "why did it fail"
helper beside an Option-returning gate would have been the two-authority drift
this PR exists to remove. `ScanError::DisallowedImplTrait` and the model's
`UnsupportedTypeReason` both carry that one reason rather than restating it.

Three further spellings were one type with different keys — the split-by-position
bug fixed for turbofish in a3cf2af, reappearing in `Fn`'s parenthesized argument
list and its bound list. They canonicalize at the same boundary, by the rule
settled last round: a trailing input comma, an explicitly-spelled `-> ()`, and
bound order (traits sorted, lifetime bounds last so the result stays valid
Rust). Since every accepted callback is therefore unit-returning with a fixed
bound order, `SourceType::Callback` needs no output field and its projection
stays exact.

Verified both new rules are load-bearing by reverting each: the refusal matrix
accepts the non-unit callback again, and the normalization test reports
`Sync + Send` against `Send + Sync`.

Generated output byte-identical in-repo, and the sibling zenoh-flat-jni
regenerates identically to a3cf2af. covertest PASS, smoke.c PASS.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* Fix normalization idempotence, a false HRTB claim, and a layering inversion

Three findings on ca21e16, all correct.

**Idempotence.** `visit_path_arguments_mut` tested whether the `Fn` return was
`()` BEFORE calling the recursion that unwraps `Paren`, so
`impl Fn(u8) -> (())` needed two passes to reach `impl Fn(u8)`. Items are
normalized at ingest and `TypeKey::from_type` normalizes again, while a directly
built key normalizes once — so a key could depend on how many passes its input
had had. The recursion moves to the top, matching `visit_type_impl_trait_mut`,
which already had the right order.

The regression is the general property, not the instance: `normalize_is_idempotent`
asserts `canon(canon(x)) == canon(x)` over every spelling the suite exercises,
because the way this breaks is a rule reading a node the recursion has not
reached yet, and that mistake belongs to no particular rule.

**The HRTB claim was wrong.** `ca21e16` refused an explicit `for<'a>` binder as
definitively unsupported, reasoning that no FFI boundary can be generic over a
lifetime. The counter-example is decisive: `impl Fn(&u8)` IS higher-ranked — it
desugars to `impl for<'a> Fn(&'a u8)` — and the two are mutually substitutable.
The binder constrains the Rust closure; it is not carried on any wire. So the
accepted spelling and the refused one are the same type, which makes this the
last two-spellings-one-type case rather than an impossibility.

Reclassified to RESERVED with the real reason: the canonicalization is not
written, and it has to be exact, since elision gives each elided input lifetime
its own fresh binder — `for<'a> Fn(&'a u8, &'a u8)` is NOT `Fn(&u8, &u8)`.
Issue #222 carries that rule. The diagnostic now points at the elided form,
which is accepted and means the same thing.

**Core depended on an adapter.** `extract_fn_trait_sig` called
`jnigen::util::is_unit` — a language-neutral source rule reaching into one
adapter, the inversion #211 exists to prevent. Core's own `is_unit` is ungated
and jnigen's is DELETED rather than re-exported: it had exactly one other
caller, and two names for one predicate is what let the inversion happen
unnoticed.

Verified the idempotence fix is load-bearing by moving the recursion back —
both the general assertion and the `-> (())` row fail, reproducing the review
exactly. Generated output byte-identical in-repo and in the sibling
zenoh-flat-jni. Builds clean with and without default features.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* The HRTB fix-it is only exact when elision preserves the binding

Follow-up on 2b84e02. That commit corrected the HRTB *classification* but left
the advice unconditional: "write the elided form instead (`Fn(&T)`, which means
exactly the same thing)". True for `for<'a> Fn(&'a T)`, false for the rest of
the refused class — the enum's own doc gives the counterexample two lines
above. Elision binds each input separately, so `Fn(&T, &T)` is
`for<'a, 'b> Fn(&'a T, &'b T)`; an author whose signature ties two inputs to one
lifetime was being told to make a semantic change and told it was exact.

The message now states the condition: exact when every bound lifetime is used
once, and no accepted spelling yet when one is used twice. `source-language.md`
carried the same unconditional sentence and gets the same treatment.

The acceptance matrix still labelled the row UNSUPPORTED and repeated the
retracted "no FFI boundary can be generic over a lifetime" claim, so the
executable document disagreed with both the enum and the guide. Relabelled
RESERVED with the actual reason. Checked that no copy of the retracted claim
survives anywhere in the tree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant