Skip to main content

alef_codegen/conversions/
helpers.rs

1use ahash::{AHashMap, AHashSet};
2use alef_core::ir::{ApiSurface, EnumDef, FieldDef, PrimitiveType, TypeDef, TypeRef};
3
4/// Collect all Named type names that appear in the API surface — both as
5/// function/method input parameters AND as function/method return types.
6/// These are types that need binding→core `From` impls.
7///
8/// Return types need binding→core From impls because:
9/// - Users may construct binding types and convert them to core types
10/// - Generated code may use `.into()` on nested Named fields in From impls
11/// - Round-trip conversion completeness ensures the API is fully usable
12///
13/// The result includes transitive dependencies: if `ConversionResult` is a
14/// return type and it has a field `metadata: HtmlMetadata`, then `HtmlMetadata`
15/// is also included.
16pub fn input_type_names(surface: &ApiSurface) -> AHashSet<String> {
17    let mut names = AHashSet::new();
18
19    // Collect Named types from function params
20    for func in &surface.functions {
21        for param in &func.params {
22            collect_named_types(&param.ty, &mut names);
23        }
24    }
25    // Collect Named types from method params
26    for typ in surface.types.iter().filter(|typ| !typ.is_trait) {
27        for method in &typ.methods {
28            for param in &method.params {
29                collect_named_types(&param.ty, &mut names);
30            }
31        }
32    }
33    // Collect Named types from function return types.
34    // Return types and their transitive field types need binding→core From impls
35    // for round-trip conversion completeness.
36    for func in &surface.functions {
37        collect_named_types(&func.return_type, &mut names);
38    }
39    // Collect Named types from method return types.
40    for typ in surface.types.iter().filter(|typ| !typ.is_trait) {
41        for method in &typ.methods {
42            collect_named_types(&method.return_type, &mut names);
43        }
44    }
45    // Collect Named types from fields of non-opaque types that have methods.
46    // When a non-opaque type has methods, codegen generates binding→core struct conversion
47    // (gen_lossy_binding_to_core_fields) which calls `.into()` on Named fields.
48    // Those field types need binding→core From impls.
49    for typ in surface.types.iter().filter(|typ| !typ.is_trait) {
50        if !typ.is_opaque && !typ.methods.is_empty() {
51            for field in &typ.fields {
52                if !field.sanitized {
53                    collect_named_types(&field.ty, &mut names);
54                }
55            }
56        }
57    }
58
59    // Transitive closure: if type A is an input and has field of type B, B is also an input
60    let mut changed = true;
61    while changed {
62        changed = false;
63        let snapshot: Vec<String> = names.iter().cloned().collect();
64        for name in &snapshot {
65            if let Some(typ) = surface.types.iter().find(|t| t.name == *name) {
66                for field in &typ.fields {
67                    let mut field_names = AHashSet::new();
68                    collect_named_types(&field.ty, &mut field_names);
69                    for n in field_names {
70                        if names.insert(n) {
71                            changed = true;
72                        }
73                    }
74                }
75            }
76        }
77    }
78
79    names
80}
81
82/// Recursively collect all `Named(name)` from a TypeRef.
83fn collect_named_types(ty: &TypeRef, out: &mut AHashSet<String>) {
84    match ty {
85        TypeRef::Named(name) => {
86            out.insert(name.clone());
87        }
88        TypeRef::Optional(inner) | TypeRef::Vec(inner) => collect_named_types(inner, out),
89        TypeRef::Map(k, v) => {
90            collect_named_types(k, out);
91            collect_named_types(v, out);
92        }
93        _ => {}
94    }
95}
96
97/// Check if a TypeRef references a Named type that is in the exclude list.
98/// Used to skip fields whose types were excluded from binding generation,
99/// preventing references to non-existent wrapper types (e.g. Js* in WASM).
100pub fn field_references_excluded_type(ty: &TypeRef, exclude_types: &[String]) -> bool {
101    match ty {
102        TypeRef::Named(name) => exclude_types.iter().any(|e| e == name),
103        TypeRef::Optional(inner) | TypeRef::Vec(inner) => field_references_excluded_type(inner, exclude_types),
104        TypeRef::Map(k, v) => {
105            field_references_excluded_type(k, exclude_types) || field_references_excluded_type(v, exclude_types)
106        }
107        _ => false,
108    }
109}
110
111/// Returns true if a primitive type needs i64 casting (NAPI/PHP — JS/PHP lack native u64).
112pub(crate) fn needs_i64_cast(p: &PrimitiveType) -> bool {
113    matches!(p, PrimitiveType::U64 | PrimitiveType::Usize | PrimitiveType::Isize)
114}
115
116/// Returns the core primitive type string for cast primitives.
117pub(crate) fn core_prim_str(p: &PrimitiveType) -> &'static str {
118    match p {
119        PrimitiveType::U64 => "u64",
120        PrimitiveType::Usize => "usize",
121        PrimitiveType::Isize => "isize",
122        PrimitiveType::F32 => "f32",
123        PrimitiveType::Bool => "bool",
124        PrimitiveType::U8 => "u8",
125        PrimitiveType::U16 => "u16",
126        PrimitiveType::U32 => "u32",
127        PrimitiveType::I8 => "i8",
128        PrimitiveType::I16 => "i16",
129        PrimitiveType::I32 => "i32",
130        PrimitiveType::I64 => "i64",
131        PrimitiveType::F64 => "f64",
132    }
133}
134
135/// Returns the binding primitive type string for cast primitives (core→binding direction).
136pub(crate) fn binding_prim_str(p: &PrimitiveType) -> &'static str {
137    match p {
138        PrimitiveType::U64 | PrimitiveType::Usize | PrimitiveType::Isize => "i64",
139        PrimitiveType::F32 => "f64",
140        PrimitiveType::Bool => "bool",
141        PrimitiveType::U8 | PrimitiveType::U16 | PrimitiveType::U32 => "i32",
142        PrimitiveType::I8 | PrimitiveType::I16 | PrimitiveType::I32 => "i32",
143        PrimitiveType::I64 => "i64",
144        PrimitiveType::F64 => "f64",
145    }
146}
147
148/// Build the set of types that can have core→binding From safely generated.
149/// More permissive than binding→core: allows sanitized fields (uses format!("{:?}"))
150/// and accepts data enums (data discarded with `..` in match arms).
151pub fn core_to_binding_convertible_types(surface: &ApiSurface) -> AHashSet<String> {
152    let convertible_enums: AHashSet<&str> = surface
153        .enums
154        .iter()
155        .filter(|e| can_generate_enum_conversion_from_core(e))
156        .map(|e| e.name.as_str())
157        .collect();
158
159    let opaque_type_names: AHashSet<&str> = surface
160        .types
161        .iter()
162        .filter(|t| t.is_opaque)
163        .map(|t| t.name.as_str())
164        .collect();
165
166    // Build rust_path maps for detecting type_rust_path mismatches.
167    let (enum_paths, type_paths) = build_rust_path_maps(surface);
168
169    // All non-opaque types are candidates (sanitized fields use format!("{:?}"))
170    let mut convertible: AHashSet<String> = surface
171        .types
172        .iter()
173        .filter(|t| !t.is_opaque)
174        .map(|t| t.name.clone())
175        .collect();
176
177    let mut changed = true;
178    while changed {
179        changed = false;
180        let snapshot: Vec<String> = convertible.iter().cloned().collect();
181        let mut known: AHashSet<&str> = convertible.iter().map(|s| s.as_str()).collect();
182        known.extend(&opaque_type_names);
183        let mut to_remove = Vec::new();
184        for type_name in &snapshot {
185            if let Some(typ) = surface.types.iter().find(|t| t.name == *type_name) {
186                let ok = typ.fields.iter().all(|f| {
187                    if f.sanitized {
188                        true
189                    } else if field_has_path_mismatch(f, &enum_paths, &type_paths) {
190                        false
191                    } else {
192                        is_field_convertible(&f.ty, &convertible_enums, &known)
193                    }
194                });
195                if !ok {
196                    to_remove.push(type_name.clone());
197                }
198            }
199        }
200        for name in to_remove {
201            if convertible.remove(&name) {
202                changed = true;
203            }
204        }
205    }
206    convertible
207}
208
209/// Build the set of types that can have binding→core From safely generated.
210/// Strict: excludes types with sanitized fields (lossy conversion).
211/// This is transitive: a type is convertible only if all its Named field types
212/// are also convertible (or are enums with From/Into support).
213pub fn convertible_types(surface: &ApiSurface) -> AHashSet<String> {
214    // Build set of enums that have From/Into impls (unit-variant enums only)
215    let convertible_enums: AHashSet<&str> = surface
216        .enums
217        .iter()
218        .filter(|e| can_generate_enum_conversion(e))
219        .map(|e| e.name.as_str())
220        .collect();
221
222    // Build set of all known type names (including opaques) — opaque Named fields
223    // are convertible because we wrap/unwrap them via Arc.
224    let _all_type_names: AHashSet<&str> = surface.types.iter().map(|t| t.name.as_str()).collect();
225
226    // Build set of Named types that implement Default — sanitized fields referencing
227    // Named types without Default would cause a compile error in the generated From impl.
228    let default_type_names: AHashSet<&str> = surface
229        .types
230        .iter()
231        .filter(|t| t.has_default)
232        .map(|t| t.name.as_str())
233        .collect();
234
235    // Start with all non-opaque types as candidates.
236    // Types with sanitized fields use Default::default() for the sanitized field
237    // in the binding→core direction — but only if the field type implements Default.
238    let mut convertible: AHashSet<String> = surface
239        .types
240        .iter()
241        .filter(|t| !t.is_opaque)
242        .map(|t| t.name.clone())
243        .collect();
244
245    // Set of opaque type names — Named fields referencing opaques are always convertible
246    // (they use Arc wrap/unwrap), so include them in the known-types check.
247    let opaque_type_names: AHashSet<&str> = surface
248        .types
249        .iter()
250        .filter(|t| t.is_opaque)
251        .map(|t| t.name.as_str())
252        .collect();
253
254    // Build rust_path maps for detecting type_rust_path mismatches.
255    let (enum_paths, type_paths) = build_rust_path_maps(surface);
256
257    // Iteratively remove types whose fields reference non-convertible Named types.
258    // We check against `convertible ∪ opaque_types` so that types referencing
259    // excluded types (e.g. types with sanitized fields) are transitively removed,
260    // while opaque Named fields remain valid.
261    let mut changed = true;
262    while changed {
263        changed = false;
264        let snapshot: Vec<String> = convertible.iter().cloned().collect();
265        let mut known: AHashSet<&str> = convertible.iter().map(|s| s.as_str()).collect();
266        known.extend(&opaque_type_names);
267        let mut to_remove = Vec::new();
268        for type_name in &snapshot {
269            if let Some(typ) = surface.types.iter().find(|t| t.name == *type_name) {
270                let ok = typ.fields.iter().all(|f| {
271                    if f.sanitized {
272                        sanitized_field_has_default(&f.ty, &default_type_names)
273                    } else if field_has_path_mismatch(f, &enum_paths, &type_paths) {
274                        false
275                    } else {
276                        is_field_convertible(&f.ty, &convertible_enums, &known)
277                    }
278                });
279                if !ok {
280                    to_remove.push(type_name.clone());
281                }
282            }
283        }
284        for name in to_remove {
285            if convertible.remove(&name) {
286                changed = true;
287            }
288        }
289    }
290    convertible
291}
292
293/// Check if a sanitized field's type can produce a valid `Default::default()` expression.
294/// Primitive types, strings, collections, Options, and Named types with `has_default` are fine.
295/// Named types without `has_default` are not — generating `Default::default()` for them would
296/// fail to compile.
297fn sanitized_field_has_default(ty: &TypeRef, default_types: &AHashSet<&str>) -> bool {
298    match ty {
299        TypeRef::Primitive(_)
300        | TypeRef::String
301        | TypeRef::Char
302        | TypeRef::Bytes
303        | TypeRef::Path
304        | TypeRef::Unit
305        | TypeRef::Duration
306        | TypeRef::Json => true,
307        // Option<T> defaults to None regardless of T
308        TypeRef::Optional(_) => true,
309        // Vec<T> defaults to empty vec regardless of T
310        TypeRef::Vec(_) => true,
311        // Map<K, V> defaults to empty map regardless of K/V
312        TypeRef::Map(_, _) => true,
313        TypeRef::Named(name) => {
314            if is_tuple_type_name(name) {
315                // Tuple types are always passthrough
316                true
317            } else {
318                // Named type must have has_default to be safely used via Default::default()
319                default_types.contains(name.as_str())
320            }
321        }
322    }
323}
324
325/// Check if a specific type is in the convertible set.
326pub fn can_generate_conversion(typ: &TypeDef, convertible: &AHashSet<String>) -> bool {
327    convertible.contains(&typ.name)
328}
329
330pub(crate) fn is_field_convertible(
331    ty: &TypeRef,
332    convertible_enums: &AHashSet<&str>,
333    known_types: &AHashSet<&str>,
334) -> bool {
335    match ty {
336        TypeRef::Primitive(_)
337        | TypeRef::String
338        | TypeRef::Char
339        | TypeRef::Bytes
340        | TypeRef::Path
341        | TypeRef::Unit
342        | TypeRef::Duration => true,
343        TypeRef::Json => true,
344        TypeRef::Optional(inner) | TypeRef::Vec(inner) => is_field_convertible(inner, convertible_enums, known_types),
345        TypeRef::Map(k, v) => {
346            is_field_convertible(k, convertible_enums, known_types)
347                && is_field_convertible(v, convertible_enums, known_types)
348        }
349        // Tuple types are passthrough — always convertible
350        TypeRef::Named(name) if is_tuple_type_name(name) => true,
351        // Unit-variant enums and known types (including opaques, which use Arc wrap/unwrap) are convertible.
352        TypeRef::Named(name) => convertible_enums.contains(name.as_str()) || known_types.contains(name.as_str()),
353    }
354}
355
356/// Check if a field's `type_rust_path` is compatible with the known type/enum rust_paths.
357///
358/// When a struct field has a `type_rust_path` that differs from the `rust_path` of the
359/// enum or type with the same short name, the `.into()` conversion will fail because
360/// the `From` impl targets a different type. This detects such mismatches.
361fn field_has_path_mismatch(
362    field: &FieldDef,
363    enum_rust_paths: &AHashMap<&str, &str>,
364    type_rust_paths: &AHashMap<&str, &str>,
365) -> bool {
366    let name = match &field.ty {
367        TypeRef::Named(n) => n.as_str(),
368        TypeRef::Optional(inner) | TypeRef::Vec(inner) => match inner.as_ref() {
369            TypeRef::Named(n) => n.as_str(),
370            _ => return false,
371        },
372        _ => return false,
373    };
374
375    if let Some(field_path) = &field.type_rust_path {
376        if let Some(enum_path) = enum_rust_paths.get(name) {
377            if !paths_compatible(field_path, enum_path) {
378                return true;
379            }
380        }
381        if let Some(type_path) = type_rust_paths.get(name) {
382            if !paths_compatible(field_path, type_path) {
383                return true;
384            }
385        }
386    }
387    false
388}
389
390/// Check if two rust paths refer to the same type.
391///
392/// Handles re-exports: `crate::module::Type` and `crate::Type` are compatible
393/// when they share the same crate root and type name (the type is re-exported).
394fn paths_compatible(a: &str, b: &str) -> bool {
395    if a == b {
396        return true;
397    }
398    // Normalize dashes to underscores for crate name comparison
399    // (Cargo uses dashes in package names, Rust uses underscores in crate names)
400    let a_norm = a.replace('-', "_");
401    let b_norm = b.replace('-', "_");
402    if a_norm == b_norm {
403        return true;
404    }
405    // Direct suffix match (e.g., "foo::Bar" ends_with "Bar")
406    if a_norm.ends_with(&b_norm) || b_norm.ends_with(&a_norm) {
407        return true;
408    }
409    // Same crate root + same short name → likely a re-export
410    let a_root = a_norm.split("::").next().unwrap_or("");
411    let b_root = b_norm.split("::").next().unwrap_or("");
412    let a_name = a_norm.rsplit("::").next().unwrap_or("");
413    let b_name = b_norm.rsplit("::").next().unwrap_or("");
414    a_root == b_root && a_name == b_name
415}
416
417/// Build maps of name -> rust_path for enums and types in the API surface.
418fn build_rust_path_maps(surface: &ApiSurface) -> (AHashMap<&str, &str>, AHashMap<&str, &str>) {
419    let enum_paths: AHashMap<&str, &str> = surface
420        .enums
421        .iter()
422        .map(|e| (e.name.as_str(), e.rust_path.as_str()))
423        .collect();
424    let type_paths: AHashMap<&str, &str> = surface
425        .types
426        .iter()
427        .map(|t| (t.name.as_str(), t.rust_path.as_str()))
428        .collect();
429    (enum_paths, type_paths)
430}
431
432/// Check if an enum can have From/Into safely generated (both directions).
433/// All enums are allowed — data variants use Default::default() for non-simple fields
434/// in the binding→core direction.
435pub fn can_generate_enum_conversion(enum_def: &EnumDef) -> bool {
436    !enum_def.variants.is_empty()
437}
438
439/// Check if an enum can have core→binding From safely generated.
440/// This is always possible: unit variants map 1:1, data variants discard data with `..`.
441pub fn can_generate_enum_conversion_from_core(enum_def: &EnumDef) -> bool {
442    // Always possible — data variants are handled by pattern matching with `..`
443    !enum_def.variants.is_empty()
444}
445
446/// Returns true if fields represent a tuple variant (positional: _0, _1, ...).
447pub fn is_tuple_variant(fields: &[FieldDef]) -> bool {
448    !fields.is_empty()
449        && fields[0]
450            .name
451            .strip_prefix('_')
452            .is_some_and(|rest: &str| rest.chars().all(|c: char| c.is_ascii_digit()))
453}
454
455/// Returns true if a TypeDef represents a newtype struct (single unnamed field `_0`).
456pub fn is_newtype(typ: &TypeDef) -> bool {
457    typ.fields.len() == 1 && typ.fields[0].name == "_0"
458}
459
460/// Returns true if a type name looks like a tuple (starts with `(`).
461/// Tuple types are passthrough — no conversion needed.
462pub(crate) fn is_tuple_type_name(name: &str) -> bool {
463    name.starts_with('(')
464}
465
466/// Derive the Rust import path from rust_path, replacing hyphens with underscores.
467pub fn core_type_path(typ: &TypeDef, core_import: &str) -> String {
468    // rust_path is like "liter-llm::tower::RateLimitConfig"
469    // We need "liter_llm::tower::RateLimitConfig"
470    let path = typ.rust_path.replace('-', "_");
471    // If the path starts with the core_import, use it directly
472    if path.starts_with(core_import) {
473        path
474    } else {
475        // Fallback: just use core_import::name
476        format!("{core_import}::{}", typ.name)
477    }
478}
479
480/// Check if a type has any sanitized fields (binding→core conversion is lossy).
481pub fn has_sanitized_fields(typ: &TypeDef) -> bool {
482    typ.fields.iter().any(|f| f.sanitized)
483}
484
485/// Derive the Rust import path for an enum, replacing hyphens with underscores.
486pub fn core_enum_path(enum_def: &EnumDef, core_import: &str) -> String {
487    let path = enum_def.rust_path.replace('-', "_");
488    if path.starts_with(core_import) {
489        path
490    } else {
491        format!("{core_import}::{}", enum_def.name)
492    }
493}
494
495/// Build a map from type/enum short name to full rust_path.
496///
497/// Used by backends to resolve `TypeRef::Named(name)` to the correct qualified path
498/// instead of assuming `core_import::name` (which fails for types not re-exported at crate root).
499pub fn build_type_path_map(surface: &ApiSurface, core_import: &str) -> AHashMap<String, String> {
500    let mut map = AHashMap::new();
501    for typ in surface.types.iter().filter(|typ| !typ.is_trait) {
502        let path = typ.rust_path.replace('-', "_");
503        let resolved = if path.starts_with(core_import) {
504            path
505        } else {
506            format!("{core_import}::{}", typ.name)
507        };
508        map.insert(typ.name.clone(), resolved);
509    }
510    for en in &surface.enums {
511        let path = en.rust_path.replace('-', "_");
512        let resolved = if path.starts_with(core_import) {
513            path
514        } else {
515            format!("{core_import}::{}", en.name)
516        };
517        map.insert(en.name.clone(), resolved);
518    }
519    map
520}
521
522/// Resolve a `TypeRef::Named` short name to its full qualified path.
523///
524/// If the name is in the path map, returns the full path; otherwise falls back
525/// to `core_import::name`.
526pub fn resolve_named_path(name: &str, core_import: &str, path_map: &AHashMap<String, String>) -> String {
527    if let Some(path) = path_map.get(name) {
528        path.clone()
529    } else {
530        format!("{core_import}::{name}")
531    }
532}
533
534/// Generate a match arm for binding -> core direction.
535/// Binding enums are always unit-variant-only. Core enums may have data variants.
536/// For data variants: `BindingEnum::Variant => CoreEnum::Variant(Default::default(), ...)`
537pub fn binding_to_core_match_arm(binding_prefix: &str, variant_name: &str, fields: &[FieldDef]) -> String {
538    binding_to_core_match_arm_ext(binding_prefix, variant_name, fields, false)
539}
540
541/// Like `binding_to_core_match_arm` but `binding_has_data` controls whether the binding
542/// enum has the variant's fields (true) or is unit-only (false, e.g. Rustler/Elixir).
543pub fn binding_to_core_match_arm_ext(
544    binding_prefix: &str,
545    variant_name: &str,
546    fields: &[FieldDef],
547    binding_has_data: bool,
548) -> String {
549    if fields.is_empty() {
550        format!("{binding_prefix}::{variant_name} => Self::{variant_name},")
551    } else if !binding_has_data {
552        // Binding is unit-only: use Default for core fields
553        if is_tuple_variant(fields) {
554            let defaults: Vec<&str> = fields.iter().map(|_| "Default::default()").collect();
555            format!(
556                "{binding_prefix}::{variant_name} => Self::{variant_name}({}),",
557                defaults.join(", ")
558            )
559        } else {
560            let defaults: Vec<String> = fields
561                .iter()
562                .map(|f| format!("{}: Default::default()", f.name))
563                .collect();
564            format!(
565                "{binding_prefix}::{variant_name} => Self::{variant_name} {{ {} }},",
566                defaults.join(", ")
567            )
568        }
569    } else if is_tuple_variant(fields) {
570        // Binding uses struct syntax with _0, _1 etc., core uses tuple syntax
571        let field_names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
572        let binding_pattern = field_names.join(", ");
573        // Wrap boxed fields with Box::new() and convert Named types with .into()
574        let core_args: Vec<String> = fields
575            .iter()
576            .map(|f| {
577                let name = &f.name;
578                let expr = if matches!(&f.ty, TypeRef::Named(_)) {
579                    format!("{name}.into()")
580                } else if f.sanitized {
581                    format!("serde_json::from_str(&{name}).unwrap_or_default()")
582                } else {
583                    name.clone()
584                };
585                if f.is_boxed { format!("Box::new({expr})") } else { expr }
586            })
587            .collect();
588        format!(
589            "{binding_prefix}::{variant_name} {{ {binding_pattern} }} => Self::{variant_name}({}),",
590            core_args.join(", ")
591        )
592    } else {
593        // Destructure binding named fields and pass to core, with .into() for Named types
594        let field_names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
595        let pattern = field_names.join(", ");
596        let core_fields: Vec<String> = fields
597            .iter()
598            .map(|f| {
599                if matches!(&f.ty, TypeRef::Named(_)) {
600                    format!("{}: {}.into()", f.name, f.name)
601                } else if f.sanitized {
602                    // Sanitized fields have a simplified type in the binding (e.g. String)
603                    // but the core type is complex (e.g. Vec<(String,String)>).
604                    // Deserialize from JSON string for the binding→core conversion.
605                    format!("{}: serde_json::from_str(&{}).unwrap_or_default()", f.name, f.name)
606                } else {
607                    format!("{0}: {0}", f.name)
608                }
609            })
610            .collect();
611        format!(
612            "{binding_prefix}::{variant_name} {{ {pattern} }} => Self::{variant_name} {{ {} }},",
613            core_fields.join(", ")
614        )
615    }
616}
617
618/// Generate a match arm for core -> binding direction.
619/// When the binding also has data variants, destructure and forward fields.
620/// When the binding is unit-variant-only, discard core data with `..`.
621pub fn core_to_binding_match_arm(core_prefix: &str, variant_name: &str, fields: &[FieldDef]) -> String {
622    core_to_binding_match_arm_ext(core_prefix, variant_name, fields, false)
623}
624
625/// Like `core_to_binding_match_arm` but `binding_has_data` controls whether the binding
626/// enum has the variant's fields (true) or is unit-only (false).
627pub fn core_to_binding_match_arm_ext(
628    core_prefix: &str,
629    variant_name: &str,
630    fields: &[FieldDef],
631    binding_has_data: bool,
632) -> String {
633    if fields.is_empty() {
634        format!("{core_prefix}::{variant_name} => Self::{variant_name},")
635    } else if !binding_has_data {
636        // Binding is unit-only: discard core data
637        if is_tuple_variant(fields) {
638            format!("{core_prefix}::{variant_name}(..) => Self::{variant_name},")
639        } else {
640            format!("{core_prefix}::{variant_name} {{ .. }} => Self::{variant_name},")
641        }
642    } else if is_tuple_variant(fields) {
643        // Core uses tuple syntax, binding uses struct syntax with _0, _1 etc.
644        let field_names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
645        let core_pattern = field_names.join(", ");
646        // Unbox and convert Named types with .into()
647        let binding_fields: Vec<String> = fields
648            .iter()
649            .map(|f| {
650                let name = &f.name;
651                let expr = if f.is_boxed && matches!(&f.ty, TypeRef::Named(_)) {
652                    format!("(*{name}).into()")
653                } else if f.is_boxed {
654                    format!("*{name}")
655                } else if matches!(&f.ty, TypeRef::Named(_)) {
656                    format!("{name}.into()")
657                } else if f.sanitized {
658                    format!("serde_json::to_string(&{name}).unwrap_or_default()")
659                } else {
660                    name.clone()
661                };
662                format!("{name}: {expr}")
663            })
664            .collect();
665        format!(
666            "{core_prefix}::{variant_name}({core_pattern}) => Self::{variant_name} {{ {} }},",
667            binding_fields.join(", ")
668        )
669    } else {
670        let field_names: Vec<&str> = fields.iter().map(|f| f.name.as_str()).collect();
671        let pattern = field_names.join(", ");
672        let binding_fields: Vec<String> = fields
673            .iter()
674            .map(|f| {
675                if matches!(&f.ty, TypeRef::Named(_)) {
676                    format!("{}: {}.into()", f.name, f.name)
677                } else if f.sanitized {
678                    // Sanitized fields have a simplified type in the binding (e.g. String)
679                    // but the core type is complex (e.g. Vec<(String,String)>).
680                    // Serialize to JSON string for the conversion.
681                    format!("{}: serde_json::to_string(&{}).unwrap_or_default()", f.name, f.name)
682                } else {
683                    format!("{0}: {0}", f.name)
684                }
685            })
686            .collect();
687        format!(
688            "{core_prefix}::{variant_name} {{ {pattern} }} => Self::{variant_name} {{ {} }},",
689            binding_fields.join(", ")
690        )
691    }
692}