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
668
669
670
671
672
673
674
//! #52: idiomatic Kotlin overloads for expanded params.
//!
//! A multi-variant `expand_param!(T)` crosses the wire as a selector tuple
//! (`expectedSel: Int, expected00: Long?, …`); the raw call site passes magic
//! ints and null-padding. Two mechanisms turn that into idiomatic Kotlin:
//!
//! * **Proactive splittability check** ([`Declarations::validate_split_declarations`]):
//!   every multi-variant `expand_param!` declaration (type-level or per-fn) is
//!   verified up front to be *splittable* — its arms surface as pairwise-distinct
//!   JVM signatures — so a function can safely request overloads. A collision is
//!   a hard build error attributed to the declaration; `.no_split()` opts out.
//! * **Per-function emission** ([`render_param_overloads`], driven by
//!   [`FunctionDecl::split_on_param`](prebindgen_registry::fun)): for the named split params the
//!   generator emits, alongside the selector wrapper, the **cartesian product** of
//!   their arms as typed overloads, each delegating to the selector form. The
//!   concrete product must have no two combinations sharing a JVM signature.
//!
//! Only flat arms are splittable; an explicit `.split_on_param` on a
//! recursively-built / single-variant / unknown parameter is a hard error.
//!
//! **Optional params (nullable-arm rule)** — an `Option<…>` parameter is
//! splittable through its **single-leaf** arms only (the identity arm, or a
//! build arm with exactly one constructor input): the overload keeps the arm's
//! nullable type (`T?`) and `null` selects absence (selector `-1`), mirroring
//! the "one nullable leaf decides presence" convention of optional single-ctor
//! expansion. Multi-leaf arms have no nullable single type and stay
//! selector-only; an optional param with no single-leaf arm is a hard error.
//! With two qualifying nullable arms a bare `null` argument is ambiguous at
//! Kotlin call sites (both mean absent) and needs a cast — signatures are
//! still pairwise-distinct per the checks above.

#[cfg(test)]
use kotlin_codegen::KtVis;
use kotlin_codegen::{KtBody, KtCode, KtFun, KtParam, KtType};
use prebindgen_registry::{
    expand::{FoldArg, FoldPlan},
    Conversions,
};

use super::*;

impl Declarations {
    /// Proactively verify every multi-variant `expand_param!` declaration is
    /// splittable (its arms have pairwise-distinct JVM-erased signatures), so
    /// [`FunctionDecl::split_on_param`](prebindgen_registry::fun) can emit unambiguous
    /// overloads. Runs regardless of whether any function actually splits the
    /// type, so authorship errors surface early. `.no_split()` opts a decl out.
    /// A collision errors with a message attributed to the declaration
    /// (surfaced through the [`validate_resolved`] boundary before any
    /// artifact is written).
    ///
    /// [`validate_resolved`]: prebindgen_registry::Prebindgen::validate_resolved
    pub(crate) fn validate_split_declarations(
        &self,
        registry: &Registry<KotlinMeta>,
    ) -> Result<(), String> {
        let type_level = self
            .param_expand_decls
            .iter()
            .map(|d| (d.key().as_str().to_string(), d));
        let per_fn = self
            .fn_param_expands
            .iter()
            .map(|(func, param, d)| (format!("fun `{func}` param `{param}`"), d));
        for (site, decl) in type_level.chain(per_fn) {
            if decl.is_no_split() || decl.variants().len() < 2 {
                continue;
            }
            let target = decl.rust_type().key();
            let sigs: Vec<(String, Vec<ErasedJvmType>)> = decl
                .variants()
                .iter()
                .map(|v| {
                    let ctor = match v {
                        LocalVariant::Ctor(c) => Some(c),
                        LocalVariant::SelfIdentity => None,
                    };
                    (
                        ctor.map(|c| c.to_string())
                            .unwrap_or_else(|| "variant_self()".to_string()),
                        arm_erased_sig(self, registry, &target, ctor),
                    )
                })
                .collect();
            for i in 0..sigs.len() {
                for j in (i + 1)..sigs.len() {
                    if sigs[i].1 == sigs[j].1 {
                        return Err(format!(
                            "expand_param!({t}) [{site}]: variants {a} and {b} both surface as \
                             `({sig})` — a split would emit two overloads with the same JVM \
                             signature; disambiguate the constructors or add .no_split()",
                            t = decl.key().as_str(),
                            a = sigs[i].0,
                            b = sigs[j].0,
                            sig = sigs[i]
                                .1
                                .iter()
                                .map(|e| e.to_string())
                                .collect::<Vec<_>>()
                                .join(", "),
                        ));
                    }
                }
            }
        }
        Ok(())
    }
}

/// The JVM-erased type list of one arm: the constructor's parameter types
/// (build arm), or the single target type (`variant_self`, `ctor == None`).
/// Uses the shared [`erase_kt_type`] model (issue #89 stage 2) so the split
/// ambiguity check and the whole-artifact overload table agree on erasure.
fn arm_erased_sig(
    ext: &Declarations,
    registry: &Registry<KotlinMeta>,
    target: &TypeKey,
    ctor: Option<&syn::Ident>,
) -> Vec<ErasedJvmType> {
    match ctor {
        Some(cf) => match registry.flat().function(&cf) {
            Some(f) => f
                .params
                .iter()
                .map(|p| rust_type_erased(ext, registry, &p.ty))
                .collect(),
            None => Vec::new(),
        },
        // The identity, because the two callers reach it differently: one holds
        // a plan's reading, the other a `.expand_param(...)` DECLARATION, which
        // the build script wrote and the model may never have interned. Where
        // it did, the reading answers as before; where it did not, the erased
        // form is the identity's own canonical spelling — which is all a
        // declaration has to be told apart by.
        None => vec![match registry.reading(target) {
            Some(reading) => rust_type_erased(ext, registry, &reading),
            None => ErasedJvmType::raw(target.as_str().to_string()),
        }],
    }
}

/// The [`ErasedJvmType`] a Rust arm type surfaces as: map it to its Kotlin
/// surface type (a declared class's FQN, else the resolved converter's Kotlin
/// name) and run the shared [`erase_kt_type`]; a plain class folds to its FQN
/// there. Falls back to the token string for a
/// type with no resolved surface. References are peeled first (`&T` erases
/// like `T`).
fn rust_type_erased(
    ext: &Declarations,
    registry: &Registry<KotlinMeta>,
    ty: &prebindgen_registry::flat::TypeRef,
) -> ErasedJvmType {
    // The spelling's own outermost borrow, as `TypeKind::Ref` — not
    // `borrow_target`, which reaches through the erased wrappers (#S31).
    let peeled = match ty.kind() {
        prebindgen_registry::flat::TypeKind::Ref { inner, .. } => inner,
        _ => ty,
    };
    let key = peeled.key();
    if ext.types.get(&key).is_some_and(|c| c.name_spec.is_some()) {
        if let Some(fqn) = ext.kotlin_fqn(&key) {
            return erase_kt_type(&[], &KtType::cls(fqn));
        }
    }
    if let Some(kt) = registry
        .input_entry(peeled)
        .and_then(|e| e.metadata.kotlin_name.clone())
    {
        return erase_kt_type(&[], &kt);
    }
    // The type's IDENTITY, not its spelling. `ErasedJvmType` is a string
    // compared with `==` — "two parameters collide as overloads iff their
    // tokens are equal" — so keying it on what the source wrote meant two
    // spellings of one type (`Box<Payload>` and `Payload`, `crate::Foo` and
    // `Foo`) erased to different tokens and were judged NOT to collide. The
    // JVM would then reject the emitted class for a clash this check exists
    // to catch first. A `TypeKey` is canonical, so equal types compare equal.
    ErasedJvmType::raw(peeled.key().as_str())
}

/// Whether `plan` is a multi-variant expansion that can be turned into
/// overloads: a selector (≥2 arms) and no recursively-built arm. An `Option`
/// outer shape is in scope — its arms are filtered to single-leaf ones by
/// [`resolve_split`] (nullable-arm rule, see module docs).
fn plan_in_scope(plan: &FoldPlan) -> bool {
    plan.selector.is_some()
        && !plan
            .variants
            .iter()
            .any(|v| v.inputs.iter().any(|a| matches!(a, FoldArg::Build(_))))
}

/// One resolved split parameter of a function, positioned against the rendered
/// selector wrapper.
struct Split<'a> {
    /// The original Rust parameter ident (e.g. `expected`).
    param: syn::Ident,
    plan: &'a FoldPlan,
    /// Start of this param's contiguous leaf block in `sel_fun.params`.
    start: usize,
    /// Block length (`plan.leaves.len()`).
    len: usize,
    /// Selector-leaf index within the block.
    sel_idx: usize,
    /// `Option<…>` parameter — overload arms keep their nullable type and the
    /// delegated selector is conditional (`null` = absent = `-1`).
    optional: bool,
    /// Overloadable arms: original variant index (the selector value) plus the
    /// arm's typed params as `(param, leaf-index-within-block)`. For an
    /// optional param this is the single-leaf subset of the variants.
    arms: Vec<(usize, Vec<(KtParam, usize)>)>,
}

/// Camel-cased Kotlin names of a `#[prebindgen]` constructor's parameters.
/// The Kotlin names of a constructor's parameters.
///
/// Off `Function::params`, where a parameter **is** a name and a reading. The
/// `sig.inputs` walk this replaced had to skip a receiver it could not have
/// (the frontend refuses one) and match `Pat::Ident` for a pattern the frontend
/// already required.
fn ctor_param_names(f: &prebindgen_registry::flat::Function) -> Vec<String> {
    f.params
        .iter()
        .map(|p| kt_param_name(&p.name.to_string()))
        .collect()
}

/// `origin` + Capitalized `name` (`primary` + `count` → `primaryCount`).
fn prefixed(origin: &str, name: &str) -> String {
    let mut c = name.chars();
    match c.next() {
        Some(first) => format!("{origin}{}{}", first.to_uppercase(), c.as_str()),
        None => origin.to_string(),
    }
}

/// Clear selector-dispatch nullability (`T?` → `T`). Genuinely optional
/// constructor parameters bypass this helper and retain their rendered `T?`.
fn non_null(mut ty: KtType) -> KtType {
    match &mut ty {
        KtType::Named { nullable, .. } | KtType::Function { nullable, .. } => *nullable = false,
    }
    ty
}

/// The typed overload params of one variant arm, paired with the leaf index
/// each fills. `origin`/`multi` drive name disambiguation (build-arm params are
/// prefixed with the origin parameter name when the function splits more than
/// one parameter). `optional_plan` keeps every slot's rendered nullability —
/// for an `Option<…>` parameter `null` encodes absence (nullable-arm rule).
/// Returns `None` if any input is not a flat leaf.
fn variant_typed_params(
    registry: &impl Conversions<KotlinMeta>,
    variant: &prebindgen_registry::expand::FoldVariant,
    origin: &syn::Ident,
    block: &[KtParam],
    multi: bool,
    optional_plan: bool,
) -> Option<Vec<(KtParam, usize)>> {
    let origin_kt = kt_param_name(&origin.to_string());
    let (names, optional): (Vec<String>, Vec<bool>) = match &variant.ctor {
        Some(cf) => {
            let f = registry.flat().function(&cf)?;
            // `Optional` off the kind, not `is_option` off a path: the same
            // question, asked of the grammar the source wrote.
            let optional = f
                .params
                .iter()
                .map(|p| {
                    matches!(
                        p.ty.kind(),
                        prebindgen_registry::flat::TypeKind::Optional(_)
                    )
                })
                .collect();
            (ctor_param_names(f), optional)
        }
        // Identity arm: one parameter, the value itself, named after the origin
        // parameter (already unique across split params — never prefixed).
        None => (vec![origin_kt.clone()], vec![false]),
    };
    let mut out = Vec::new();
    for (m, arg) in variant.inputs.iter().enumerate() {
        let FoldArg::Leaf(idx, _) = arg else {
            return None;
        };
        let slot = block.get(*idx)?;
        let base = names.get(m).cloned().unwrap_or_else(|| slot.name.clone());
        let name = if multi && variant.ctor.is_some() {
            prefixed(&origin_kt, &base)
        } else {
            base
        };
        let ty = if optional_plan || optional.get(m).copied().unwrap_or(false) {
            slot.ty.clone()
        } else {
            non_null(slot.ty.clone())
        };
        out.push((KtParam::new(&name, ty), *idx));
    }
    Some(out)
}

/// Locate a param's contiguous leaf block in the selector wrapper's parameter
/// list, matching by leaf name in order.
fn find_block(params: &[KtParam], leaf_names: &[String]) -> Option<usize> {
    if leaf_names.is_empty() || params.len() < leaf_names.len() {
        return None;
    }
    (0..=params.len() - leaf_names.len()).find(|&s| {
        params[s..s + leaf_names.len()]
            .iter()
            .zip(leaf_names)
            .all(|(p, n)| &p.name == n)
    })
}

/// Resolve one `.split_on_param` request against the rendered selector wrapper,
/// validating the parameter is expandable, multi-variant, and in-scope (all
/// hard errors — the user explicitly asked to split it).
fn resolve_split<'a>(
    registry: &'a Registry<KotlinMeta>,
    f: &prebindgen_registry::flat::Function,
    sel_fun: &KtFun,
    param_name: &str,
    multi: bool,
) -> Split<'a> {
    let param = syn::Ident::new(param_name, Span::call_site());
    let plan = registry
        .expansion_plans()
        .get(&(f.name.clone(), param.clone()))
        .unwrap_or_else(|| {
            panic!(
                "fun!({}).split_on_param(\"{param_name}\"): `{param_name}` is not an expandable \
                 parameter (it has no `expand_param!` variants)",
                f.name
            )
        });
    assert!(
        plan.selector.is_some(),
        "fun!({}).split_on_param(\"{param_name}\"): `{param_name}` has a single variant — there \
         is nothing to split (it already flattens to one signature)",
        f.name
    );
    assert!(
        plan_in_scope(plan),
        "fun!({}).split_on_param(\"{param_name}\"): `{param_name}` has a recursively-built arm — \
         it cannot be overloaded; keep the selector form",
        f.name
    );
    let leaf_names: Vec<String> = plan
        .leaves
        .iter()
        .map(|l| kt_param_name(&l.name.to_string()))
        .collect();
    let len = leaf_names.len();
    let start = find_block(&sel_fun.params, &leaf_names).unwrap_or_else(|| {
        panic!(
            "fun!({}).split_on_param(\"{param_name}\"): could not locate the parameter's leaf \
             block in the generated wrapper",
            f.name
        )
    });
    let block = &sel_fun.params[start..start + len];
    let sel_idx = plan.selector.expect("selector present");
    // Nullable-arm rule: an `Option<…>` param is overloadable through its
    // single-leaf arms only — the arm's one nullable param doubles as the
    // presence flag (`null` = absent). Multi-leaf arms stay selector-only.
    let optional = plan.produces_option();
    let arms: Vec<(usize, Vec<(KtParam, usize)>)> = plan
        .variants
        .iter()
        .enumerate()
        .filter(|(_, v)| !optional || v.inputs.len() == 1)
        .map(|(vi, v)| {
            let typed = variant_typed_params(registry, v, &param, block, multi, optional)
                .unwrap_or_else(|| {
                    panic!(
                        "fun!({}).split_on_param(\"{param_name}\"): an arm has a non-flat input; \
                         it cannot be overloaded",
                        f.name
                    )
                });
            (vi, typed)
        })
        .collect();
    assert!(
        !arms.is_empty(),
        "fun!({}).split_on_param(\"{param_name}\"): `{param_name}` is an `Option<_>` parameter \
         and none of its arms is a single leaf — its overload has no clean nullable type; keep \
         the selector form",
        f.name
    );
    Split {
        param,
        plan,
        start,
        len,
        sel_idx,
        optional,
        arms,
    }
}

/// The overloads for one function, delegating to its already-rendered selector
/// wrapper `sel_fun`. Empty unless the function has `.split_on_param` requests.
/// Emits the cartesian product of the named params' arms; panics (a build
/// error) if the product has two combinations with the same JVM signature.
pub(crate) fn render_param_overloads(
    ext: &Declarations,
    f: &prebindgen_registry::flat::Function,
    registry: &Registry<KotlinMeta>,
    sel_fun: &KtFun,
) -> Vec<KtFun> {
    // Requested split params for this function, in signature order.
    let requested: Vec<String> = {
        let want: std::collections::HashSet<&str> = ext
            .fn_split_params
            .iter()
            .filter(|(func, _)| func == &f.name)
            .map(|(_, p)| p.as_str())
            .collect();
        if want.is_empty() {
            return Vec::new();
        }
        f.params
            .iter()
            .filter(|p| want.contains(p.name.to_string().as_str()))
            .map(|p| p.name.to_string())
            .collect()
    };
    // Any requested name that didn't match a real parameter is a typo — surface
    // it rather than silently dropping.
    for (func, p) in &ext.fn_split_params {
        if func == &f.name && !requested.iter().any(|r| r == p) {
            panic!(
                "fun!({}).split_on_param(\"{p}\"): no parameter named `{p}` on this function",
                f.name
            );
        }
    }

    let multi = requested.len() > 1;
    let splits: Vec<Split> = requested
        .iter()
        .map(|name| resolve_split(registry, f, sel_fun, name, multi))
        .collect();

    // Cartesian product of arm indices across all split params.
    let combos = cartesian(&splits.iter().map(|s| s.arms.len()).collect::<Vec<_>>());

    // Product-global JVM-signature collision check (fixed params are identical
    // across every overload, so only the split-arm lists can collide).
    let sigs: Vec<Vec<ErasedJvmType>> = combos
        .iter()
        .map(|combo| {
            splits
                .iter()
                .zip(combo)
                .flat_map(|(s, &ai)| {
                    let ctor = s.plan.variants[s.arms[ai].0].ctor.as_ref();
                    arm_erased_sig(ext, registry, &s.plan.target.key(), ctor)
                })
                .collect()
        })
        .collect();
    for i in 0..sigs.len() {
        for j in (i + 1)..sigs.len() {
            if sigs[i] == sigs[j] {
                panic!(
                    "fun!({}): split_on_param product is ambiguous — combinations {} and {} both \
                     surface as `({})`; add .no_split() intent is not enough here, disambiguate \
                     the constructors or drop one .split_on_param",
                    f.name,
                    combo_label(&splits, &combos[i]),
                    combo_label(&splits, &combos[j]),
                    sigs[i]
                        .iter()
                        .map(|e| e.to_string())
                        .collect::<Vec<_>>()
                        .join(", "),
                );
            }
        }
    }

    // Emit one overload per product combination.
    let n = sel_fun.params.len();
    let mut out = Vec::with_capacity(combos.len());
    for combo in &combos {
        // Per-split delegation slots, keyed by block start.
        let mut params: Vec<KtParam> = Vec::new();
        let mut call_args: Vec<String> = Vec::new();
        let mut pos = 0usize;
        while pos < n {
            if let Some((si, s)) = splits.iter().enumerate().find(|(_, s)| s.start == pos) {
                // Replace this param's whole leaf block with the chosen arm's
                // typed params; fill the block's delegation slots.
                let (vi, typed) = &s.arms[combo[si]];
                let mut leaf_arg: Vec<String> = vec!["null".to_string(); s.len];
                leaf_arg[s.sel_idx] = if s.optional {
                    // Nullable-arm rule: the arm's single param doubles as the
                    // presence flag — `null` delegates absence (selector -1).
                    format!("if ({} != null) {vi} else -1", typed[0].0.name)
                } else {
                    vi.to_string()
                };
                for (p, lidx) in typed {
                    params.push(p.clone());
                    leaf_arg[*lidx] = p.name.clone();
                }
                call_args.extend(leaf_arg);
                pos += s.len;
            } else {
                // A fixed (or non-split expanded) param — passes through.
                let p = &sel_fun.params[pos];
                params.push(p.clone());
                call_args.push(p.name.clone());
                pos += 1;
            }
        }

        // Guard against a param-name clash (e.g. an arm name colliding with a
        // fixed param) rather than emitting uncompilable Kotlin.
        let mut seen = std::collections::HashSet::new();
        for p in &params {
            assert!(
                seen.insert(p.name.clone()),
                "fun!({}): split overload has a duplicate parameter name `{}` — rename the \
                 constructor parameter",
                f.name,
                p.name
            );
        }

        out.push(overload_shell(
            sel_fun,
            params,
            KtCode::new().line(format!("{}({})", sel_fun.name, call_args.join(", "))),
        ));
    }
    out
}

/// An overload derived from the selector wrapper by a signature-preserving
/// transform: generics, annotations, modifiers, visibility, and return type
/// carry over unchanged (a wrapper generic over `R`/`A` — builder/fold
/// delivery — keeps its `fun <R> …` declaration, #87); only the parameter
/// list and the delegating body are replaced. Kdoc stays on the selector
/// form — overloads are bare.
fn overload_shell(sel_fun: &KtFun, params: Vec<KtParam>, body: KtCode) -> KtFun {
    let mut ov = sel_fun.clone();
    ov.params = params;
    ov.kdoc = None;
    ov.body = KtBody::Expr(body);
    ov
}

/// Cartesian product of index ranges `0..counts[k]`, as a list of index
/// tuples. `[2, 2]` → `[[0,0],[0,1],[1,0],[1,1]]`.
fn cartesian(counts: &[usize]) -> Vec<Vec<usize>> {
    let mut acc = vec![Vec::new()];
    for &c in counts {
        acc = acc
            .into_iter()
            .flat_map(|prefix| {
                (0..c).map(move |i| {
                    let mut next = prefix.clone();
                    next.push(i);
                    next
                })
            })
            .collect();
    }
    acc
}

/// A `param=variant` label for one product combination, for error messages.
fn combo_label(splits: &[Split], combo: &[usize]) -> String {
    let parts: Vec<String> = splits
        .iter()
        .zip(combo)
        .map(|(s, &ai)| {
            let v = match &s.plan.variants[s.arms[ai].0].ctor {
                Some(c) => c.to_string(),
                None => "variant_self()".to_string(),
            };
            format!("{}={v}", s.param)
        })
        .collect();
    format!("({})", parts.join(", "))
}

#[cfg(test)]
mod tests {
    use prebindgen::SourceLocation;

    use super::*;

    #[test]
    fn split_params_preserve_constructor_option_nullability() {
        let ctor: syn::ItemFn = syn::parse_quote! {
            pub fn z_summary_optional(count: Option<i64>, total: f64) -> ZSummary {
                unimplemented!()
            }
        };
        let registry = crate::test_util::reg_from_items(crate::test_util::declare_referenced(
            vec![(syn::Item::Fn(ctor), SourceLocation::default())],
        ))
        .expect("index constructor");
        let variant = prebindgen_registry::expand::FoldVariant {
            ctor: Some(syn::parse_quote!(z_summary_optional)),
            fallible: false,
            clone: false,
            inputs: vec![FoldArg::Leaf(0, false), FoldArg::Leaf(1, false)],
        };
        // Both slots are nullable in the selector wrapper. Only the first is
        // nullable in the constructor's actual signature.
        let block = vec![
            KtParam::new("expected0", KtType::long().nullable()),
            KtParam::new("expected1", KtType::cls("Double").nullable()),
        ];

        let params = variant_typed_params(
            &registry,
            &variant,
            &syn::parse_quote!(expected),
            &block,
            false,
            false,
        )
        .expect("flat arm");

        assert_eq!(params[0].0.ty.to_string(), "Long?");
        assert_eq!(params[1].0.ty.to_string(), "Double");
    }

    /// #87: an overload preserves the selector wrapper's signature metadata —
    /// generics (`fun <R> …`), annotations, modifiers, visibility, and return
    /// type — replacing only the parameter list and body; kdoc stays on the
    /// selector form.
    #[test]
    fn overload_shell_preserves_signature_metadata() {
        let sel_fun = KtFun::new("storageSummary")
            .vis(KtVis::Public)
            .kdoc("Selector-form docs.")
            .generic("R")
            .annotation("Suppress(\"UNCHECKED_CAST\")")
            .modifier("inline")
            .param(KtParam::new("sSel", KtType::int()))
            .returns(KtType::cls("R"))
            .body(KtCode::new().line("TODO()"));

        let ov = overload_shell(
            &sel_fun,
            vec![KtParam::new("count", KtType::long())],
            KtCode::new().line("storageSummary(0, count)"),
        );

        assert_eq!(ov.name, sel_fun.name);
        assert_eq!(ov.generics, vec!["R".to_string()]);
        assert_eq!(ov.annotations, sel_fun.annotations);
        assert_eq!(ov.modifiers, sel_fun.modifiers);
        assert!(matches!(ov.vis, KtVis::Public));
        assert_eq!(
            ov.ret.as_ref().map(|t| t.to_string()),
            Some("R".to_string())
        );
        assert_eq!(ov.kdoc, None);
        assert_eq!(ov.params.len(), 1);
        assert_eq!(ov.params[0].name, "count");
        assert!(matches!(ov.body, KtBody::Expr(_)));
    }
}