prebindgen-jni 0.5.0

JNI / Kotlin binding generator for prebindgen
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
//! Struct outputs: `fromParts` leaf encoding and the value-struct
//! synthesis probe.

use prebindgen_registry::Conversions;

use super::*;

/// Resolve the typed-handle Kotlin FQN for a handle-bearing struct field
/// and assert its folded strategy is one the struct encode/decode bridge
/// supports. Today only scalar handle slots (`Direct`, optionally wrapped
/// in `Nullable`) are encodable as a single `L<FQN>;` ctor arg; a
/// collection layer (`Iterable`, i.e. `Vec<Handle>`) would need array
/// codegen and is a loud build-time error until implemented.
pub(crate) fn handle_field_fqn(ext: &Declarations, h: &Projection) -> String {
    fn assert_scalar(s: &FoldStrategy) {
        match s {
            FoldStrategy::Base => {}
            FoldStrategy::Optional(_, inner) => assert_scalar(inner),
            FoldStrategy::Iterable(_) => panic!(
                "struct handle field: collection (Vec<Handle>) layers are not yet \
                 supported by the struct encode/decode bridge — add array codegen \
                 to struct_output_body/struct_input_body to lift this guard"
            ),
        }
    }
    assert_scalar(&h.strategy);
    ext.kotlin_fqn(&h.leaf_key)
        .map(|v| v.to_string())
        .unwrap_or_else(|| {
            panic!(
                "struct handle field: leaf `{}` has no Kotlin FQN registered \
                 (ptr_class)",
                h.leaf_key
            )
        })
}

/// One flattened leaf wire slot of a struct's recursive `fromParts` encode
/// (see [`flatten_struct_encode`]). `ident` holds the encoded wire after the
/// preludes run; `default` is the value used for this slot when it sits under
/// an absent `Option<nested>` parent.
pub(crate) struct EncSlot {
    ident: proc_macro2::Ident,
    wire_ty: TokenStream,
    descriptor: String,
    is_object: bool,
    default: TokenStream,
}

/// Zero/null wire value for a JVM descriptor — used to fill an absent
/// `Option<nested>`'s leaf slots (the Kotlin `present` flag tells the factory
/// to ignore them).
pub(crate) fn primitive_default_for_descriptor(sig: &str) -> TokenStream {
    match sig {
        "Z" => quote!(0u8),
        "B" => quote!(0i8),
        "C" => quote!(0u16),
        "S" => quote!(0i16),
        "I" => quote!(0i32),
        "J" => quote!(0i64),
        "F" => quote!(0.0f32),
        "D" => quote!(0.0f64),
        _ => quote!(jni::objects::JObject::null()),
    }
}

/// Synthesize the [`LeafSource::Field`](prebindgen_registry::unfold::LeafSource)
/// leaves of a by-value `data_class` for the fixed-builder output/callback path
/// — the pre-resolve analog of [`flatten_struct_encode`] (which runs at emit
/// time). Each named field becomes one field-access leaf
/// (`name`, `path = [..field idents]`, `out_ty = <field type>`); a non-optional
/// nested data-class field recurses (inlined), so the whole graph crosses as
/// decoupled leaves the foreign side reassembles.
///
/// Returns `None` (⇒ the type keeps the whole-value `fromParts` path) when a
/// field needs a transform this fixed builder can't yet forward verbatim — a
/// **projection** (opaque handle), an **enum**, or a nested
/// data-class behind `Option` / `Vec`. (Those are handled by the slower
/// [`struct_output_body`] until the synthesizer is widened to wrap them.)
///
/// Classification reads only `ext.types` (`opaque`/`enum_cfg`) and the parsed
/// model (`registry.flat()`) — both populated before `resolve` — never the
/// output converter table (not yet built at this stage).
pub(crate) fn synth_value_struct_leaves(
    ext: &Declarations,
    registry: &impl Conversions<KotlinMeta>,
    s: &prebindgen_registry::flat::Struct,
    path_prefix: &[prebindgen_registry::unfold::PathStep],
    name_prefix: &str,
    depth: usize,
) -> Option<Vec<prebindgen_registry::unfold::UnfoldLeaf>> {
    use prebindgen_registry::unfold::{LeafSource, PathStep, UnfoldLeaf};
    if depth > 16 {
        return None;
    }
    // Named by construction — a tuple struct is an `Extern`, not a `Struct`.
    let mut leaves: Vec<UnfoldLeaf> = Vec::new();
    for field in &s.fields {
        let fname = field.name.as_ref()?.clone();
        let camel = mangle_kotlin_ident(&kt_snake_to_camel(&fname.to_string()));
        let leaf_name = if name_prefix.is_empty() {
            camel
        } else {
            format!("{name_prefix}__{camel}")
        };
        let mut path = path_prefix.to_vec();
        // The synthesizer declines `Option`-wrapped nesting below, so an
        // intermediate step is never optional; a TERMINAL `Option` field is not
        // a nesting step either (its own converter carries the nullability).
        path.push(PathStep::field(fname, false));

        // A projection field (opaque handle) or an enum field
        // is delivered with a transform the fixed builder can't forward yet.
        // A nested data-class field (a *declared* plain struct) inlines when
        // non-optional (recurse); `Option`/`Vec`-wrapped nesting is deferred
        // to the whole-value path.
        // Both layer questions off the field's own reading: `Optional` to look
        // through, `Vec` to defer — the kinds, not a last path segment.
        let probe = field.ty.optional_inner().unwrap_or(&field.ty);
        let nested = match ext.type_kind(registry, &probe.key()) {
            // A sum joins the kinds this fixed builder cannot forward: it has
            // no single leaf and no converter of its own — it crosses as a tag
            // plus one group per variant, which only the whole-value
            // `fromParts` path (`PlanFieldKind::Sum`) can lay out. Falling
            // through to the simple-leaf arm below would silently synthesize a
            // leaf whose `out_ty` is the sum and then REQUIRE an output
            // converter for it, failing the resolve with the sum named rather
            // than the unsupported position.
            TypeKind::Handle | TypeKind::Enum | TypeKind::Sum => return None,
            TypeKind::DataStruct { st, cfg: Some(_) } => Some(st.clone()),
            _ => None,
        };
        if let Some(child) = nested {
            if field.ty.optional_inner().is_some()
                || matches!(field.ty.kind(), prebindgen_registry::flat::TypeKind::Vec(_))
            {
                return None;
            }
            let child_leaves =
                synth_value_struct_leaves(ext, registry, &child, &path, &leaf_name, depth + 1)?;
            leaves.extend(child_leaves);
            continue;
        }

        // Simple leaf: scalar / String / Option<Box<String>> / ByteArray / Vec.
        // The field's own output converter (resolved later) encodes it; the
        // foreign `fromParts` forwards it verbatim. Nullability is carried by
        // the converter (e.g. `Option<Box<String>>` → `String?`), so the leaf
        // itself isn't path-nullable.
        leaves.push(UnfoldLeaf {
            name: leaf_name,
            path,
            out_ty: field.ty.clone(),
            identity: false,
            nullable: false,
            source: LeafSource::Field,
            group: None,
        });
    }
    Some(leaves)
}

/// Recursively flatten a struct's output encode into a list of leaf wire
/// slots plus the preludes that compute them, so the whole object graph can
/// be built by a **single** Kotlin `fromParts` call (no per-nested-struct
/// `call_static_method`). Nested non-optional data-class fields are inlined;
/// nested `Option<data-class>` fields emit a `present` `jboolean` slot followed
/// by the child's leaves (encoded in the `Some` arm, defaulted in the `None`
/// arm). Leaves (primitives, handles→`jlong`, enums→`jint`, strings, arrays,
/// `Vec`) terminate the recursion.
///
/// The field classification is the shared [`build_struct_plan`] — the same
/// plan `flatten_struct_factory` walks for the Kotlin side, so the slot
/// order and JVM descriptors agree by construction.
pub(crate) fn flatten_struct_encode(
    ext: &Declarations,
    registry: &impl Conversions<KotlinMeta>,
    s: &prebindgen_registry::flat::Struct,
    access: &TokenStream,
    prefix: &str,
    depth: usize,
    env_expr: &TokenStream,
) -> Option<(TokenStream, Vec<EncSlot>)> {
    let plan = build_struct_plan(ext, registry, s, depth)?;
    Some(encode_plan(&plan, access, prefix, depth, env_expr))
}

/// Walk a [`StructPlan`] emitting the Rust-side wire encode: per leaf a
/// prelude statement binding `__<prefix>_<field>` to the converted wire and
/// an [`EncSlot`] describing its `JValue` slot. `access` is the Rust
/// expression yielding the current struct value (`v`, `v.field`, or the
/// matched `__cN` under an Option); `prefix` namespaces the generated idents.
fn encode_plan(
    plan: &StructPlan,
    access: &TokenStream,
    prefix: &str,
    depth: usize,
    env_expr: &TokenStream,
) -> (TokenStream, Vec<EncSlot>) {
    let mut preludes = TokenStream::new();
    let mut slots: Vec<EncSlot> = Vec::new();

    for f in &plan.fields {
        let fname = &f.fname;
        let base = format!("{}_{}", prefix, fname);
        let value = quote! { #access.#fname };
        let (pre, sl) = encode_field(&f.kind, &value, &base, depth, env_expr);
        preludes.extend(pre);
        slots.extend(sl);
    }
    (preludes, slots)
}

/// Emit the Rust-side wire encode of ONE value position — a struct field or a
/// sum's variant payload. `value` is the Rust expression yielding it (`v.mode`
/// at a struct field, the bound pattern variable inside a variant arm), which
/// is what lets a sum reuse this for its payloads: a payload is encoded by the
/// same code as a field of the same type, not by a parallel walk.
fn encode_field(
    kind: &PlanFieldKind,
    value: &TokenStream,
    base: &str,
    depth: usize,
    env_expr: &TokenStream,
) -> (TokenStream, Vec<EncSlot>) {
    let mut preludes = TokenStream::new();
    let mut slots: Vec<EncSlot> = Vec::new();
    {
        let id = format_ident!("__{}", base);
        // The leaf's COMPLETE chain, not just its wire-facing converter: a
        // `convert!`-declared type (`Duration`) reaches the wire through its
        // rust-side stages first (`Duration → u64 → jlong`).
        let conv_value = |conv: &ConvChain| -> TokenStream { conv.call(env_expr, value, base) };
        match kind {
            // Projection leaf (opaque handle → jlong, `ULong` → jlong).
            PlanFieldKind::Projection { conv, proj, .. } => {
                let value_expr = conv_value(conv);
                match proj.kind {
                    ProjectionKind::Handle => {
                        preludes.extend(quote! { let #id: jni::sys::jlong = #value_expr; });
                        slots.push(EncSlot {
                            ident: id,
                            wire_ty: quote!(jni::sys::jlong),
                            descriptor: "J".to_string(),
                            is_object: false,
                            default: quote!(0i64),
                        });
                    }
                    ProjectionKind::Unsigned64 => match proj.strategy {
                        FoldStrategy::Base => {
                            preludes.extend(quote! { let #id: jni::sys::jlong = #value_expr; });
                            slots.push(EncSlot {
                                ident: id,
                                wire_ty: quote!(jni::sys::jlong),
                                descriptor: "J".to_string(),
                                is_object: false,
                                default: quote!(0i64),
                            });
                        }
                        FoldStrategy::Optional(NullableKind::Niche, _) => {
                            preludes.extend(quote! { let #id: jni::sys::jlong = #value_expr; });
                            slots.push(EncSlot {
                                ident: id,
                                wire_ty: quote!(jni::sys::jlong),
                                descriptor: "J".to_string(),
                                is_object: false,
                                default: quote!(0i64),
                            });
                        }
                        FoldStrategy::Optional(NullableKind::Boxed, _) => {
                            preludes
                                .extend(quote! { let #id: jni::objects::JObject = #value_expr; });
                            slots.push(EncSlot {
                                ident: id,
                                wire_ty: quote!(jni::objects::JObject),
                                descriptor: "Ljava/lang/Long;".to_string(),
                                is_object: true,
                                default: quote!(jni::objects::JObject::null()),
                            });
                        }
                        FoldStrategy::Iterable(_) => unreachable!(
                            "projection collection fields are rejected by build_struct_plan"
                        ),
                    },
                }
            }
            // Enum leaf → jint discriminant (Kotlin `fromParts` calls `fromInt`).
            PlanFieldKind::Enum { conv, .. } => {
                let value_expr = conv_value(conv);
                preludes.extend(quote! { let #id: jni::sys::jint = #value_expr; });
                slots.push(EncSlot {
                    ident: id,
                    wire_ty: quote!(jni::sys::jint),
                    descriptor: "I".to_string(),
                    is_object: false,
                    default: quote!(0i32),
                });
            }
            // `Option<enum>` leaf → the converter delivers the `box_jint`-boxed
            // discriminant (JVM null = `None`); the slot is the box class.
            PlanFieldKind::OptionEnum { conv, .. } => {
                let value_expr = conv_value(conv);
                preludes.extend(quote! { let #id: jni::objects::JObject = #value_expr; });
                slots.push(EncSlot {
                    ident: id,
                    wire_ty: quote!(jni::objects::JObject),
                    descriptor: "Ljava/lang/Integer;".to_string(),
                    is_object: true,
                    default: quote!(jni::objects::JObject::null()),
                });
            }
            // Nested data-class: inline the child's leaves; under `Option` add
            // a `present` flag and default the child slots in the `None` arm.
            PlanFieldKind::Nested {
                optional,
                plan: child,
                ..
            } => {
                if !*optional {
                    let (child_pre, child_slots) =
                        encode_plan(child, value, base, depth + 1, env_expr);
                    preludes.extend(child_pre);
                    slots.extend(child_slots);
                } else {
                    let cbind = format_ident!("__c{}", depth);
                    let child_access = quote! { #cbind };
                    let (child_pre, child_slots) =
                        encode_plan(child, &child_access, base, depth + 1, env_expr);
                    let flag_id = format_ident!("__{}_present", base);
                    let outer_ids: Vec<proc_macro2::Ident> = (0..child_slots.len())
                        .map(|i| format_ident!("__{}_o{}", base, i))
                        .collect();
                    let outer_tys: Vec<TokenStream> =
                        child_slots.iter().map(|sl| sl.wire_ty.clone()).collect();
                    let inner_ids: Vec<proc_macro2::Ident> =
                        child_slots.iter().map(|sl| sl.ident.clone()).collect();
                    let defaults: Vec<TokenStream> =
                        child_slots.iter().map(|sl| sl.default.clone()).collect();
                    // Destructured through a coercion site: `kind` says this
                    // field is optional, and how Rust spells that is the
                    // source's business (#268).
                    let obind = format_ident!("__on{}", depth);
                    let coerce = bind_as_option(&quote!(&#value), &obind);
                    preludes.extend(quote! {
                        let #flag_id: jni::sys::jboolean;
                        #( let #outer_ids: #outer_tys; )*
                        #coerce
                        match #obind {
                            ::core::option::Option::Some(#cbind) => {
                                #child_pre
                                #flag_id = 1u8;
                                #( #outer_ids = #inner_ids; )*
                            }
                            ::core::option::Option::None => {
                                #flag_id = 0u8;
                                #( #outer_ids = #defaults; )*
                            }
                        }
                    });
                    slots.push(EncSlot {
                        ident: flag_id,
                        wire_ty: quote!(jni::sys::jboolean),
                        descriptor: "Z".to_string(),
                        is_object: false,
                        default: quote!(0u8),
                    });
                    for (i, sl) in child_slots.iter().enumerate() {
                        slots.push(EncSlot {
                            ident: outer_ids[i].clone(),
                            wire_ty: sl.wire_ty.clone(),
                            descriptor: sl.descriptor.clone(),
                            is_object: sl.is_object,
                            default: sl.default.clone(),
                        });
                    }
                }
            }
            // Data-carrying enum: one `match` binds the tag and EVERY group's
            // slots — the live group from its payload, the rest from the same
            // defaults an absent `Option<nested>` uses. One crossing, no JVM
            // object built for the sum.
            PlanFieldKind::Sum {
                source,
                optional,
                variants,
                ..
            } => {
                let tag_id = format_ident!("__{}__tag", base);

                // Encode each variant's group once, against its own pattern
                // bindings. `arms` keeps them aligned with the flat slot list.
                struct Arm {
                    pattern: TokenStream,
                    preludes: TokenStream,
                    slots: Vec<EncSlot>,
                }
                let mut arms: Vec<Arm> = Vec::new();
                for v in variants {
                    let vident = &v.rust_ident;
                    let binds: Vec<syn::Ident> = (0..v.fields.len())
                        .map(|i| format_ident!("__s{}_{}", depth, i))
                        .collect();
                    let mut vpre = TokenStream::new();
                    let mut vslots: Vec<EncSlot> = Vec::new();
                    for (f, bind) in v.fields.iter().zip(&binds) {
                        let fbase = format!("{base}_{}", f.slot);
                        let bind_expr = quote!(#bind);
                        let (p, s) = encode_field(&f.kind, &bind_expr, &fbase, depth + 1, env_expr);
                        vpre.extend(p);
                        vslots.extend(s);
                    }
                    // Bind every payload field, shaped like the variant.
                    let pattern = match v.fields.first().map(|f| &f.member) {
                        None => quote!(#source::#vident),
                        Some(syn::Member::Named(_)) => {
                            let pairs = v.fields.iter().zip(&binds).map(|(f, b)| {
                                let syn::Member::Named(n) = &f.member else {
                                    unreachable!("variant field shapes are uniform")
                                };
                                quote!(#n: #b)
                            });
                            quote!(#source::#vident { #(#pairs),* })
                        }
                        Some(syn::Member::Unnamed(_)) => quote!(#source::#vident(#(#binds),*)),
                    };
                    arms.push(Arm {
                        pattern,
                        preludes: vpre,
                        slots: vslots,
                    });
                }

                // Outer bindings: the tag plus every group's slots, side by
                // side in variant order. Each arm assigns its own group from
                // the values it just computed and defaults all the others.
                let all: Vec<&EncSlot> = arms.iter().flat_map(|a| a.slots.iter()).collect();
                let outer_ids: Vec<proc_macro2::Ident> = (0..all.len())
                    .map(|i| format_ident!("__{}_g{}", base, i))
                    .collect();
                let outer_tys: Vec<TokenStream> = all.iter().map(|s| s.wire_ty.clone()).collect();
                let defaults: Vec<TokenStream> = all.iter().map(|s| s.default.clone()).collect();

                let mut offset = 0usize;
                let arm_code: Vec<TokenStream> = arms
                    .iter()
                    .enumerate()
                    .map(|(tag, a)| {
                        let n = a.slots.len();
                        let live_outer = &outer_ids[offset..offset + n];
                        let live_inner: Vec<proc_macro2::Ident> =
                            a.slots.iter().map(|s| s.ident.clone()).collect();
                        // Every slot outside this arm's own group is inert.
                        let inert_outer: Vec<proc_macro2::Ident> = outer_ids
                            .iter()
                            .enumerate()
                            .filter(|(i, _)| *i < offset || *i >= offset + n)
                            .map(|(_, id)| id.clone())
                            .collect();
                        let inert_defaults: Vec<TokenStream> = defaults
                            .iter()
                            .enumerate()
                            .filter(|(i, _)| *i < offset || *i >= offset + n)
                            .map(|(_, d)| d.clone())
                            .collect();
                        offset += n;
                        let pattern = &a.pattern;
                        let pre = &a.preludes;
                        let tag_lit = proc_macro2::Literal::i32_unsuffixed(tag as i32);
                        quote! {
                            #pattern => {
                                #pre
                                #tag_id = #tag_lit;
                                #( #live_outer = #live_inner; )*
                                #( #inert_outer = #inert_defaults; )*
                            }
                        }
                    })
                    .collect();

                let decls = quote! {
                    let #tag_id: jni::sys::jint;
                    #( let #outer_ids: #outer_tys; )*
                };
                if !*optional {
                    preludes.extend(quote! {
                        #decls
                        match &#value { #(#arm_code)* }
                    });
                } else {
                    // `Option<sum>` keeps its own present flag ahead of the
                    // tag: optionality and choice are independent facts.
                    let flag_id = format_ident!("__{}_present", base);
                    let sbind = format_ident!("__o{}", depth);
                    let inner_arms: Vec<TokenStream> =
                        arm_code.iter().map(|a| quote! { #a }).collect();
                    // Destructured through a coercion site: `kind` says this
                    // field is optional, and how Rust spells that is the
                    // source's business (#268).
                    let obind = format_ident!("__oc{}", depth);
                    let coerce = bind_as_option(&quote!(&#value), &obind);
                    preludes.extend(quote! {
                        let #flag_id: jni::sys::jboolean;
                        #decls
                        #coerce
                        match #obind {
                            ::core::option::Option::Some(#sbind) => {
                                #flag_id = 1u8;
                                match #sbind { #(#inner_arms)* }
                            }
                            ::core::option::Option::None => {
                                #flag_id = 0u8;
                                #tag_id = 0i32;
                                #( #outer_ids = #defaults; )*
                            }
                        }
                    });
                    slots.push(EncSlot {
                        ident: flag_id,
                        wire_ty: quote!(jni::sys::jboolean),
                        descriptor: "Z".to_string(),
                        is_object: false,
                        default: quote!(0u8),
                    });
                }
                slots.push(EncSlot {
                    ident: tag_id,
                    wire_ty: quote!(jni::sys::jint),
                    descriptor: "I".to_string(),
                    is_object: false,
                    default: quote!(0i32),
                });
                for (i, sl) in all.iter().enumerate() {
                    slots.push(EncSlot {
                        ident: outer_ids[i].clone(),
                        wire_ty: sl.wire_ty.clone(),
                        descriptor: sl.descriptor.clone(),
                        is_object: sl.is_object,
                        default: sl.default.clone(),
                    });
                }
            }
            // Simple leaf: bind per the plan's wire form.
            PlanFieldKind::Leaf {
                conv,
                wire,
                form,
                descriptor,
                ..
            } => {
                let value_expr = conv_value(conv);
                match form {
                    LeafForm::Prim => {
                        preludes.extend(quote! { let #id: #wire = #value_expr; });
                        slots.push(EncSlot {
                            ident: id,
                            wire_ty: quote!(#wire),
                            descriptor: descriptor.clone(),
                            is_object: false,
                            default: primitive_default_for_descriptor(descriptor),
                        });
                    }
                    LeafForm::IntoObject => {
                        preludes.extend(
                            quote! { let #id: jni::objects::JObject = #value_expr.into(); },
                        );
                        slots.push(EncSlot {
                            ident: id,
                            wire_ty: quote!(jni::objects::JObject),
                            descriptor: descriptor.clone(),
                            is_object: true,
                            default: quote!(jni::objects::JObject::null()),
                        });
                    }
                    LeafForm::Object => {
                        preludes.extend(quote! { let #id: jni::objects::JObject = #value_expr; });
                        slots.push(EncSlot {
                            ident: id,
                            wire_ty: quote!(jni::objects::JObject),
                            descriptor: descriptor.clone(),
                            is_object: true,
                            default: quote!(jni::objects::JObject::null()),
                        });
                    }
                }
            }
        }
    }
    (preludes, slots)
}

pub(crate) fn struct_output_body(
    ext: &Declarations,
    s: &prebindgen_registry::flat::Struct,
    registry: &impl Conversions<KotlinMeta>,
) -> Option<(syn::Type, syn::Expr)> {
    let struct_name = s.name.to_string();
    // Prefer the registered Kotlin FQN (`io.zenoh.jni.JniSample`) so the
    // mangle closure flows through; fall back to the bare struct ident
    // qualified with the package when no `data_class` /
    // `ptr_class` declaration exists for this Rust type.
    let struct_ident = &s.name;
    let struct_ty: syn::Type = syn::parse_quote!(#struct_ident);
    let registered_fqn = ext
        .types
        .get(&TypeKey::from_type(&struct_ty))
        .and_then(|cfg| cfg.name_spec.as_ref())
        .map(|s| ext.fqn_of(s));
    let java_class_prefix = ext.java_class_prefix();
    let java_class_name = if let Some(fqn) = registered_fqn {
        fqn.replace('.', "/")
    } else if java_class_prefix.is_empty() {
        struct_name.clone()
    } else {
        format!("{}/{}", java_class_prefix, struct_name)
    };

    // Recursively flatten the whole object graph into leaf wires, then build it
    // with ONE `call_static_method("fromParts", …)` — no per-nested-struct JNI
    // crossing. The Kotlin `fromParts` factory (recursively flattened the same
    // way in `render_data_class_source`) reassembles the graph in bytecode.
    let access = quote!(v);
    let (preludes, slots) = flatten_struct_encode(ext, registry, s, &access, "", 0, &quote!(env))?;

    let mut sig = String::from("(");
    let mut args: Vec<TokenStream> = Vec::new();
    for sl in &slots {
        sig.push_str(&sl.descriptor);
        let id = &sl.ident;
        if sl.is_object {
            args.push(quote!(jni::objects::JValue::Object(&#id)));
        } else {
            args.push(quote!(jni::objects::JValue::from(#id)));
        }
    }
    sig.push_str(&format!(")L{};", java_class_name));
    let factory_sig_lit = syn::LitStr::new(&sig, Span::call_site());

    let body: syn::Expr = syn::parse_quote!({
        #preludes
        let __obj = env.call_static_method(
            #java_class_name,
            "fromParts",
            #factory_sig_lit,
            &[#(#args),*],
        )
        .and_then(|__v| __v.l())
        .map_err(|e| <__JniErr as ::core::convert::From<String>>::from(format!("encode struct via fromParts: {}", e)))?;
        __obj
    });
    Some((syn::parse_quote!(jni::objects::JObject), body))
}

pub(crate) fn struct_module_path(
    ext: &Declarations,
    registry: &impl Conversions<KotlinMeta>,
    name: &syn::Ident,
) -> syn::Path {
    // The module the struct is reachable under from the generated file: its
    // origin crate (multi-source registries) or the default module. Takes the
    // NAME, which is all it needs — so it serves a caller holding the element
    // and one still holding the item.
    ext.fn_module(registry, name)
}

// ──────────────────────────────────────────────────────────────────────
// Enum rank-0 bodies
// ──────────────────────────────────────────────────────────────────────