Skip to main content

alef_backend_csharp/gen_bindings/
mod.rs

1use alef_core::backend::{Backend, BuildConfig, BuildDependency, Capabilities, GeneratedFile};
2use alef_core::config::{AdapterPattern, Language, ResolvedCrateConfig, resolve_output_dir};
3use alef_core::hash::{self, CommentStyle};
4use alef_core::ir::{ApiSurface, FieldDef, TypeRef};
5use heck::ToPascalCase;
6use std::collections::HashSet;
7use std::path::PathBuf;
8
9pub(super) mod enums;
10pub(super) mod errors;
11pub(super) mod functions;
12pub(super) mod methods;
13pub(super) mod types;
14
15pub struct CsharpBackend;
16
17impl CsharpBackend {
18    // lib_name comes from config.ffi_lib_name()
19}
20
21impl Backend for CsharpBackend {
22    fn name(&self) -> &str {
23        "csharp"
24    }
25
26    fn language(&self) -> Language {
27        Language::Csharp
28    }
29
30    fn capabilities(&self) -> Capabilities {
31        Capabilities {
32            supports_async: true,
33            supports_classes: true,
34            supports_enums: true,
35            supports_option: true,
36            supports_result: true,
37            ..Capabilities::default()
38        }
39    }
40
41    fn generate_bindings(&self, api: &ApiSurface, config: &ResolvedCrateConfig) -> anyhow::Result<Vec<GeneratedFile>> {
42        let namespace = config.csharp_namespace();
43        let prefix = config.ffi_prefix();
44        let lib_name = config.ffi_lib_name();
45
46        // Collect bridge param names and type aliases from trait_bridges config so we can strip
47        // them from generated function signatures and emit ConvertWithVisitor instead.
48        let bridge_param_names: HashSet<String> = config
49            .trait_bridges
50            .iter()
51            .filter_map(|b| b.param_name.clone())
52            .collect();
53        let bridge_type_aliases: HashSet<String> = config
54            .trait_bridges
55            .iter()
56            .filter_map(|b| b.type_alias.clone())
57            .collect();
58        // Only emit ConvertWithVisitor method if visitor_callbacks is explicitly enabled in FFI config
59        let has_visitor_callbacks = config.ffi.as_ref().map(|f| f.visitor_callbacks).unwrap_or(false);
60
61        // Streaming adapter methods use a callback-based C signature that P/Invoke can't call
62        // directly. Skip them in all generated method loops.
63        let streaming_methods: HashSet<String> = config
64            .adapters
65            .iter()
66            .filter(|a| matches!(a.pattern, AdapterPattern::Streaming))
67            .map(|a| a.name.clone())
68            .collect();
69
70        // Functions explicitly excluded from C# bindings (e.g., not present in the C FFI layer).
71        let exclude_functions: HashSet<String> = config
72            .csharp
73            .as_ref()
74            .map(|c| c.exclude_functions.iter().cloned().collect())
75            .unwrap_or_default();
76
77        let output_dir = resolve_output_dir(config.output_paths.get("csharp"), &config.name, "packages/csharp/");
78
79        let base_path = PathBuf::from(&output_dir).join(namespace.replace('.', "/"));
80
81        let mut files = Vec::new();
82
83        // Fallback generic exception class name (used by GetLastError and as base for typed errors)
84        let exception_class_name = format!("{}Exception", api.crate_name.to_pascal_case());
85
86        // 1. Generate NativeMethods.cs
87        files.push(GeneratedFile {
88            path: base_path.join("NativeMethods.cs"),
89            content: strip_trailing_whitespace(&functions::gen_native_methods(
90                api,
91                &namespace,
92                &lib_name,
93                &prefix,
94                &bridge_param_names,
95                &bridge_type_aliases,
96                has_visitor_callbacks,
97                &config.trait_bridges,
98                &streaming_methods,
99                &exclude_functions,
100            )),
101            generated_header: true,
102        });
103
104        // 2. Generate error types from thiserror enums (if any), otherwise generic exception
105        if !api.errors.is_empty() {
106            for error in &api.errors {
107                let error_files =
108                    alef_codegen::error_gen::gen_csharp_error_types(error, &namespace, Some(&exception_class_name));
109                for (class_name, content) in error_files {
110                    files.push(GeneratedFile {
111                        path: base_path.join(format!("{}.cs", class_name)),
112                        content: strip_trailing_whitespace(&content),
113                        generated_header: false, // already has header
114                    });
115                }
116            }
117        }
118
119        // Fallback generic exception class (always generated for GetLastError)
120        if api.errors.is_empty()
121            || !api
122                .errors
123                .iter()
124                .any(|e| format!("{}Exception", e.name) == exception_class_name)
125        {
126            files.push(GeneratedFile {
127                path: base_path.join(format!("{}.cs", exception_class_name)),
128                content: strip_trailing_whitespace(&errors::gen_exception_class(&namespace, &exception_class_name)),
129                generated_header: true,
130            });
131        }
132
133        // 3. Generate main wrapper class
134        let base_class_name = api.crate_name.to_pascal_case();
135        let wrapper_class_name = if namespace == base_class_name {
136            format!("{}Lib", base_class_name)
137        } else {
138            base_class_name
139        };
140        files.push(GeneratedFile {
141            path: base_path.join(format!("{}.cs", wrapper_class_name)),
142            content: strip_trailing_whitespace(&methods::gen_wrapper_class(
143                api,
144                &namespace,
145                &wrapper_class_name,
146                &exception_class_name,
147                &prefix,
148                &bridge_param_names,
149                &bridge_type_aliases,
150                has_visitor_callbacks,
151                &streaming_methods,
152                &exclude_functions,
153            )),
154            generated_header: true,
155        });
156
157        // 3b. Generate visitor support files when a bridge is configured.
158        if has_visitor_callbacks {
159            for (filename, content) in crate::gen_visitor::gen_visitor_files(&namespace) {
160                files.push(GeneratedFile {
161                    path: base_path.join(filename),
162                    content: strip_trailing_whitespace(&content),
163                    generated_header: true,
164                });
165            }
166            // IVisitor.cs and VisitorCallbacks.cs were removed from gen_visitor_files() in favour
167            // of the HtmlVisitorBridge path in TraitBridges.cs.  Delete any stale copies left
168            // over from earlier generator runs.
169            delete_superseded_visitor_files(&base_path)?;
170        } else {
171            // When visitor_callbacks is disabled, delete stale files from prior runs
172            // to prevent CS8632 warnings (nullable context not enabled).
173            delete_stale_visitor_files(&base_path)?;
174        }
175
176        // 3c. Generate trait bridge classes when configured.
177        if !config.trait_bridges.is_empty() {
178            let trait_defs: Vec<_> = api.types.iter().filter(|t| t.is_trait).collect();
179            let bridges: Vec<_> = config
180                .trait_bridges
181                .iter()
182                .filter_map(|cfg| {
183                    let trait_name = cfg.trait_name.clone();
184                    trait_defs
185                        .iter()
186                        .find(|t| t.name == trait_name)
187                        .map(|trait_def| (trait_name, cfg, *trait_def))
188                })
189                .collect();
190
191            if !bridges.is_empty() {
192                let (filename, content) = crate::trait_bridge::gen_trait_bridges_file(&namespace, &prefix, &bridges);
193                files.push(GeneratedFile {
194                    path: base_path.join(filename),
195                    content: strip_trailing_whitespace(&content),
196                    generated_header: true,
197                });
198            }
199        }
200
201        // Collect enum names so record generation can distinguish enum fields from class fields.
202        let enum_names: HashSet<String> = api.enums.iter().map(|e| e.name.to_pascal_case()).collect();
203
204        // Collect all opaque type names (pascal-cased) so methods on one opaque type that
205        // return another opaque type are wrapped correctly rather than JSON-serialized.
206        let all_opaque_type_names: HashSet<String> = api
207            .types
208            .iter()
209            .filter(|t| t.is_opaque)
210            .map(|t| t.name.to_pascal_case())
211            .collect();
212
213        // 4. Generate opaque handle classes
214        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
215            if typ.is_opaque {
216                let type_filename = typ.name.to_pascal_case();
217                files.push(GeneratedFile {
218                    path: base_path.join(format!("{}.cs", type_filename)),
219                    content: strip_trailing_whitespace(&types::gen_opaque_handle(
220                        typ,
221                        &namespace,
222                        &exception_class_name,
223                        &enum_names,
224                        &streaming_methods,
225                        &all_opaque_type_names,
226                    )),
227                    generated_header: true,
228                });
229            }
230        }
231
232        // Collect complex enums (enums with data variants that are untagged) — these can't be
233        // simple C# enums and should be represented as JsonElement for flexible deserialization.
234        // Tagged unions (serde_tag is set) are now generated as proper abstract records
235        // and can be deserialized as their concrete types, so they are NOT complex_enums.
236        // Externally-tagged enums (the serde default when both serde_tag and serde_untagged are
237        // absent) are also not complex — they are emitted as string enums.
238        let complex_enums: HashSet<String> = api
239            .enums
240            .iter()
241            .filter(|e| e.serde_untagged && e.variants.iter().any(|v| !v.fields.is_empty()))
242            .map(|e| e.name.to_pascal_case())
243            .collect();
244
245        // Collect enums that require a custom JsonConverter (non-standard serialized names only).
246        // Tagged unions are generated as abstract records with [JsonPolymorphic] and do NOT need
247        // a custom converter — the attribute on the type itself handles polymorphic deserialization.
248        // When a property has a custom-converter enum as its type, emit a property-level
249        // [JsonConverter] attribute so the custom converter wins over the global JsonStringEnumConverter.
250        let custom_converter_enums: HashSet<String> = api
251            .enums
252            .iter()
253            .filter(|e| {
254                // Skip tagged unions — they use [JsonPolymorphic] instead
255                let is_tagged_union = e.serde_tag.is_some() && e.variants.iter().any(|v| !v.fields.is_empty());
256                if is_tagged_union {
257                    return false;
258                }
259                // Enums with non-standard variant names need a custom converter
260                e.variants.iter().any(|v| {
261                    if let Some(ref rename) = v.serde_rename {
262                        let snake = enums::apply_rename_all(&v.name, e.serde_rename_all.as_deref());
263                        rename != &snake
264                    } else {
265                        false
266                    }
267                })
268            })
269            .map(|e| e.name.to_pascal_case())
270            .collect();
271
272        // Resolve the language-level serde rename_all strategy (always wins over IR type-level).
273        let lang_rename_all = config.serde_rename_all_for_language(Language::Csharp);
274
275        // 5. Generate record types (structs)
276        for typ in api.types.iter().filter(|typ| !typ.is_trait) {
277            if !typ.is_opaque {
278                // Skip types where all fields are unnamed tuple positions — they have no
279                // meaningful properties to expose in C#.
280                let has_named_fields = typ.fields.iter().any(|f| !is_tuple_field(f));
281                if !typ.fields.is_empty() && !has_named_fields {
282                    continue;
283                }
284                // Skip types that gen_visitor handles with richer visitor-specific versions
285                if has_visitor_callbacks && (typ.name == "NodeContext" || typ.name == "VisitResult") {
286                    continue;
287                }
288
289                let type_filename = typ.name.to_pascal_case();
290                files.push(GeneratedFile {
291                    path: base_path.join(format!("{}.cs", type_filename)),
292                    content: strip_trailing_whitespace(&types::gen_record_type(
293                        typ,
294                        &namespace,
295                        &enum_names,
296                        &complex_enums,
297                        &custom_converter_enums,
298                        &lang_rename_all,
299                        &bridge_type_aliases,
300                    )),
301                    generated_header: true,
302                });
303            }
304        }
305
306        // 6. Generate enums
307        for enum_def in &api.enums {
308            // Skip enums that gen_visitor handles with richer visitor-specific versions
309            if has_visitor_callbacks && (enum_def.name == "VisitResult" || enum_def.name == "NodeContext") {
310                continue;
311            }
312            let enum_filename = enum_def.name.to_pascal_case();
313            files.push(GeneratedFile {
314                path: base_path.join(format!("{}.cs", enum_filename)),
315                content: strip_trailing_whitespace(&enums::gen_enum(enum_def, &namespace)),
316                generated_header: true,
317            });
318        }
319
320        // 7. Generate ByteArrayToIntArrayConverter if any non-opaque type has non-optional Bytes fields.
321        // Non-optional byte[] fields must be serialized as JSON int arrays, not base64 strings.
322        let needs_byte_array_converter = api
323            .types
324            .iter()
325            .any(|t| !t.is_opaque && t.fields.iter().any(|f| !f.optional && matches!(f.ty, TypeRef::Bytes)));
326        if needs_byte_array_converter {
327            files.push(GeneratedFile {
328                path: base_path.join("ByteArrayToIntArrayConverter.cs"),
329                content: types::gen_byte_array_to_int_array_converter(&namespace),
330                generated_header: true,
331            });
332        }
333
334        // Build adapter body map (consumed by generators via body substitution)
335        let _adapter_bodies = alef_adapters::build_adapter_bodies(config, Language::Csharp)?;
336
337        // 8. Generate Directory.Build.props at the package root (always overwritten).
338        // This file enables Nullable=enable and latest LangVersion for all C# projects
339        // in the packages/csharp hierarchy without requiring per-csproj configuration.
340        files.push(GeneratedFile {
341            path: PathBuf::from("packages/csharp/Directory.Build.props"),
342            content: gen_directory_build_props(),
343            generated_header: true,
344        });
345
346        Ok(files)
347    }
348
349    /// C# wrapper class is already the public API.
350    /// The `gen_wrapper_class` (generated in `generate_bindings`) provides high-level public methods
351    /// that wrap NativeMethods (P/Invoke), marshal types, and handle errors.
352    /// No additional facade is needed.
353    fn generate_public_api(
354        &self,
355        _api: &ApiSurface,
356        _config: &ResolvedCrateConfig,
357    ) -> anyhow::Result<Vec<GeneratedFile>> {
358        // C#'s wrapper class IS the public API — no additional wrapper needed.
359        Ok(vec![])
360    }
361
362    fn build_config(&self) -> Option<BuildConfig> {
363        Some(BuildConfig {
364            tool: "dotnet",
365            crate_suffix: "",
366            build_dep: BuildDependency::Ffi,
367            post_build: vec![],
368        })
369    }
370}
371
372/// Returns true if a field is a tuple struct positional field (e.g., `_0`, `_1`, `0`, `1`).
373pub(super) fn is_tuple_field(field: &FieldDef) -> bool {
374    (field.name.starts_with('_') && field.name[1..].chars().all(|c| c.is_ascii_digit()))
375        || field.name.chars().next().is_none_or(|c| c.is_ascii_digit())
376}
377
378/// Strip trailing whitespace from every line and ensure the file ends with a single newline.
379pub(super) fn strip_trailing_whitespace(content: &str) -> String {
380    let mut result: String = content
381        .lines()
382        .map(|line| line.trim_end())
383        .collect::<Vec<_>>()
384        .join("\n");
385    if !result.ends_with('\n') {
386        result.push('\n');
387    }
388    result
389}
390
391/// Generate C# file header with hash and nullable-enable pragma.
392pub(super) fn csharp_file_header() -> String {
393    let mut out = hash::header(CommentStyle::DoubleSlash);
394    out.push_str("#nullable enable\n\n");
395    out
396}
397
398/// Generate Directory.Build.props with Nullable=enable and LangVersion=latest.
399/// This is auto-generated (overwritten on each build) so it doesn't require user maintenance.
400fn gen_directory_build_props() -> String {
401    "<!-- auto-generated by alef (generate_bindings) -->\n\
402<Project>\n  \
403<PropertyGroup>\n    \
404<Nullable>enable</Nullable>\n    \
405<LangVersion>latest</LangVersion>\n    \
406<TreatWarningsAsErrors>true</TreatWarningsAsErrors>\n  \
407</PropertyGroup>\n\
408</Project>\n"
409        .to_string()
410}
411
412/// Delete `IVisitor.cs` and `VisitorCallbacks.cs` when visitor_callbacks is enabled but the
413/// modern `HtmlVisitorBridge` / `TraitBridges.cs` path supersedes them.
414/// These files are no longer emitted by `gen_visitor_files()` but may exist on disk from older
415/// generator runs.
416fn delete_superseded_visitor_files(base_path: &std::path::Path) -> anyhow::Result<()> {
417    let superseded = ["IVisitor.cs", "VisitorCallbacks.cs"];
418    for filename in superseded {
419        let path = base_path.join(filename);
420        if path.exists() {
421            std::fs::remove_file(&path)
422                .map_err(|e| anyhow::anyhow!("Failed to delete superseded visitor file {}: {}", path.display(), e))?;
423        }
424    }
425    Ok(())
426}
427
428/// Delete stale visitor-related files when visitor_callbacks is disabled.
429/// When visitor_callbacks transitions from true → false, these files remain on disk
430/// and cause CS8632 warnings (nullable context not enabled in these files).
431fn delete_stale_visitor_files(base_path: &std::path::Path) -> anyhow::Result<()> {
432    let stale_files = vec!["IVisitor.cs", "VisitorCallbacks.cs", "NodeContext.cs", "VisitResult.cs"];
433
434    for filename in stale_files {
435        let path = base_path.join(filename);
436        if path.exists() {
437            std::fs::remove_file(&path)
438                .map_err(|e| anyhow::anyhow!("Failed to delete stale visitor file {}: {}", path.display(), e))?;
439        }
440    }
441
442    Ok(())
443}
444
445// ---------------------------------------------------------------------------
446// Helpers: P/Invoke return type mapping
447// ---------------------------------------------------------------------------
448
449use alef_core::ir::PrimitiveType;
450
451/// Returns the C# type to use in a `[DllImport]` declaration for the given return type.
452///
453/// Key differences from the high-level `csharp_type`:
454/// - Bool is marshalled as `int` (C FFI convention) — the wrapper compares != 0.
455/// - String / Named / Vec / Map / Path / Json / Bytes all come back as `IntPtr`.
456/// - Numeric primitives use their natural C# types (`nuint`, `int`, etc.).
457pub(super) fn pinvoke_return_type(ty: &TypeRef) -> &'static str {
458    match ty {
459        TypeRef::Unit => "void",
460        // Bool over FFI is a C int (0/1).
461        TypeRef::Primitive(PrimitiveType::Bool) => "int",
462        // Numeric primitives — use their real C# types.
463        TypeRef::Primitive(PrimitiveType::U8) => "byte",
464        TypeRef::Primitive(PrimitiveType::U16) => "ushort",
465        TypeRef::Primitive(PrimitiveType::U32) => "uint",
466        TypeRef::Primitive(PrimitiveType::U64) => "ulong",
467        TypeRef::Primitive(PrimitiveType::I8) => "sbyte",
468        TypeRef::Primitive(PrimitiveType::I16) => "short",
469        TypeRef::Primitive(PrimitiveType::I32) => "int",
470        TypeRef::Primitive(PrimitiveType::I64) => "long",
471        TypeRef::Primitive(PrimitiveType::F32) => "float",
472        TypeRef::Primitive(PrimitiveType::F64) => "double",
473        TypeRef::Primitive(PrimitiveType::Usize) => "ulong",
474        TypeRef::Primitive(PrimitiveType::Isize) => "long",
475        // Duration as u64
476        TypeRef::Duration => "ulong",
477        // Everything else is a pointer that needs manual marshalling.
478        TypeRef::String
479        | TypeRef::Char
480        | TypeRef::Bytes
481        | TypeRef::Optional(_)
482        | TypeRef::Vec(_)
483        | TypeRef::Map(_, _)
484        | TypeRef::Named(_)
485        | TypeRef::Path
486        | TypeRef::Json => "IntPtr",
487    }
488}
489
490/// Returns the C# type to use for a parameter in a `[DllImport]` declaration.
491///
492/// Managed reference types (Named structs, Vec, Map, Bytes, Optional of Named, etc.)
493/// cannot be directly marshalled by P/Invoke.  They must be passed as `IntPtr` (opaque
494/// handle or JSON-string pointer).  Primitive types and plain strings use their natural
495/// types.
496pub(super) fn pinvoke_param_type(ty: &TypeRef) -> &'static str {
497    match ty {
498        TypeRef::String | TypeRef::Char | TypeRef::Path | TypeRef::Json => "string",
499        // Managed objects — pass as opaque IntPtr (serialised to handle before call)
500        TypeRef::Named(_) | TypeRef::Vec(_) | TypeRef::Map(_, _) | TypeRef::Bytes | TypeRef::Optional(_) => "IntPtr",
501        TypeRef::Unit => "void",
502        TypeRef::Primitive(PrimitiveType::Bool) => "int",
503        TypeRef::Primitive(PrimitiveType::U8) => "byte",
504        TypeRef::Primitive(PrimitiveType::U16) => "ushort",
505        TypeRef::Primitive(PrimitiveType::U32) => "uint",
506        TypeRef::Primitive(PrimitiveType::U64) => "ulong",
507        TypeRef::Primitive(PrimitiveType::I8) => "sbyte",
508        TypeRef::Primitive(PrimitiveType::I16) => "short",
509        TypeRef::Primitive(PrimitiveType::I32) => "int",
510        TypeRef::Primitive(PrimitiveType::I64) => "long",
511        TypeRef::Primitive(PrimitiveType::F32) => "float",
512        TypeRef::Primitive(PrimitiveType::F64) => "double",
513        TypeRef::Primitive(PrimitiveType::Usize) => "ulong",
514        TypeRef::Primitive(PrimitiveType::Isize) => "long",
515        TypeRef::Duration => "ulong",
516    }
517}
518
519/// Returns true if a parameter should be hidden from the public API because it is a
520/// trait-bridge param (e.g. the FFI visitor handle).
521pub(super) fn is_bridge_param(
522    param: &alef_core::ir::ParamDef,
523    bridge_param_names: &HashSet<String>,
524    bridge_type_aliases: &HashSet<String>,
525) -> bool {
526    bridge_param_names.contains(&param.name)
527        || matches!(&param.ty, alef_core::ir::TypeRef::Named(n) if bridge_type_aliases.contains(n))
528}
529
530/// Does the return type need IntPtr→string marshalling in the wrapper?
531pub(super) fn returns_string(ty: &TypeRef) -> bool {
532    matches!(ty, TypeRef::String | TypeRef::Char | TypeRef::Path | TypeRef::Json)
533}
534
535/// Does the return type come back as a C int that should be converted to bool?
536pub(super) fn returns_bool_via_int(ty: &TypeRef) -> bool {
537    matches!(ty, TypeRef::Primitive(PrimitiveType::Bool))
538}
539
540/// Does the return type need JSON deserialization from an IntPtr string?
541pub(super) fn returns_json_object(ty: &TypeRef) -> bool {
542    matches!(
543        ty,
544        TypeRef::Vec(_) | TypeRef::Map(_, _) | TypeRef::Named(_) | TypeRef::Bytes | TypeRef::Optional(_)
545    )
546}
547
548/// Returns true if the FFI return type is a pointer (IntPtr), as opposed to a numeric value.
549/// Only pointer-returning functions use `IntPtr.Zero` as an error sentinel.
550pub(super) fn returns_ptr(ty: &TypeRef) -> bool {
551    matches!(
552        ty,
553        TypeRef::String
554            | TypeRef::Char
555            | TypeRef::Path
556            | TypeRef::Json
557            | TypeRef::Named(_)
558            | TypeRef::Vec(_)
559            | TypeRef::Map(_, _)
560            | TypeRef::Bytes
561            | TypeRef::Optional(_)
562    )
563}
564
565/// Returns the argument expression to pass to the native method for a given parameter.
566///
567/// For truly opaque types (is_opaque = true), the C# class wraps an IntPtr; pass `.Handle`.
568/// For data-struct `Named` types this is the handle variable (e.g. `optionsHandle`).
569/// For everything else it is the parameter name (with `!` for optional).
570pub(super) fn native_call_arg(
571    ty: &TypeRef,
572    param_name: &str,
573    optional: bool,
574    true_opaque_types: &HashSet<String>,
575) -> String {
576    match ty {
577        TypeRef::Named(type_name) if true_opaque_types.contains(type_name) => {
578            // Truly opaque: unwrap the IntPtr from the C# handle class.
579            let bang = if optional { "!" } else { "" };
580            format!("{param_name}{bang}.Handle")
581        }
582        TypeRef::Named(_) | TypeRef::Vec(_) | TypeRef::Map(_, _) => {
583            format!("{param_name}Handle")
584        }
585        TypeRef::Bytes => {
586            format!("{param_name}Handle.AddrOfPinnedObject()")
587        }
588        TypeRef::Primitive(alef_core::ir::PrimitiveType::Bool) => {
589            // FFI convention: bool marshalled as int (0 = false, non-zero = true)
590            if optional {
591                format!("({param_name}?.Value ? 1 : 0)")
592            } else {
593                format!("({param_name} ? 1 : 0)")
594            }
595        }
596        ty => {
597            if optional {
598                // For optional primitive types (e.g. ulong?, uint?), use GetValueOrDefault()
599                // to safely unwrap with a default of 0 if null. String/Char/Path/Json are
600                // reference types so `!` is correct for those.
601                let needs_value_unwrap = matches!(ty, TypeRef::Primitive(_) | TypeRef::Duration);
602                if needs_value_unwrap {
603                    format!("{param_name}.GetValueOrDefault()")
604                } else {
605                    format!("{param_name}!")
606                }
607            } else {
608                param_name.to_string()
609            }
610        }
611    }
612}
613
614/// For each `Named` parameter, emit code to serialise it to JSON and obtain a native handle.
615///
616/// For truly opaque types (is_opaque = true), the C# class already wraps the native handle, so
617/// we pass `param.Handle` directly without any JSON serialisation.
618pub(super) fn emit_named_param_setup(
619    out: &mut String,
620    params: &[alef_core::ir::ParamDef],
621    indent: &str,
622    true_opaque_types: &HashSet<String>,
623) {
624    for param in params {
625        let param_name = param.name.to_lower_camel_case();
626        let json_var = format!("{param_name}Json");
627        let handle_var = format!("{param_name}Handle");
628
629        match &param.ty {
630            TypeRef::Named(type_name) => {
631                // Truly opaque handles: the C# wrapper class holds the IntPtr directly.
632                // No from_json round-trip needed — pass .Handle directly in native_call_arg.
633                if true_opaque_types.contains(type_name) {
634                    continue;
635                }
636                let from_json_method = format!("{}FromJson", type_name.to_pascal_case());
637
638                // Config parameters: always treat as optional and default null to new instance
639                let is_config_param = param.name == "config";
640                let param_to_serialize = if is_config_param {
641                    let type_pascal = type_name.to_pascal_case();
642                    format!("({} ?? new {}())", param_name, type_pascal)
643                } else {
644                    param_name.to_string()
645                };
646
647                if param.optional && !is_config_param {
648                    out.push_str(&crate::template_env::render(
649                        "named_param_json_optional.jinja",
650                        minijinja::context! { indent, json_var => &json_var, param_name => &param_name },
651                    ));
652                } else {
653                    out.push_str(&crate::template_env::render(
654                        "named_param_json_serialize.jinja",
655                        minijinja::context! { indent, json_var => &json_var, param_name => &param_to_serialize },
656                    ));
657                }
658                out.push_str(&crate::template_env::render(
659                    "named_param_handle_from_json.jinja",
660                    minijinja::context! { indent, handle_var => &handle_var, from_json_method => &from_json_method, json_var => &json_var },
661                ));
662            }
663            TypeRef::Vec(_) | TypeRef::Map(_, _) => {
664                // Vec/Map: serialize to JSON string, marshal to native pointer
665                out.push_str(&crate::template_env::render(
666                    "named_param_json_serialize.jinja",
667                    minijinja::context! { indent, json_var => &json_var, param_name => &param_name },
668                ));
669                out.push_str(&crate::template_env::render(
670                    "named_param_handle_string.jinja",
671                    minijinja::context! { indent, handle_var => &handle_var, json_var => &json_var },
672                ));
673            }
674            TypeRef::Bytes => {
675                // byte[]: pin the managed array and pass pointer to native
676                out.push_str(&crate::template_env::render(
677                    "named_param_handle_pin.jinja",
678                    minijinja::context! { indent, handle_var => &handle_var, param_name => &param_name },
679                ));
680            }
681            _ => {}
682        }
683    }
684}
685
686/// Emit cleanup code to free native handles allocated for `Named` parameters.
687///
688/// Truly opaque handles (is_opaque = true) are NOT freed here — their lifetime is managed by
689/// the C# wrapper class (IDisposable). Only data-struct handles (from_json-allocated) are freed.
690pub(super) fn emit_named_param_teardown(
691    out: &mut String,
692    params: &[alef_core::ir::ParamDef],
693    true_opaque_types: &HashSet<String>,
694) {
695    for param in params {
696        let param_name = param.name.to_lower_camel_case();
697        let handle_var = format!("{param_name}Handle");
698        match &param.ty {
699            TypeRef::Named(type_name) => {
700                if true_opaque_types.contains(type_name) {
701                    // Caller owns the opaque handle — do not free it here.
702                    continue;
703                }
704                let free_method = format!("{}Free", type_name.to_pascal_case());
705                out.push_str(&crate::template_env::render(
706                    "named_param_teardown_free.jinja",
707                    minijinja::context! { indent => "        ", free_method => &free_method, handle_var => &handle_var },
708                ));
709            }
710            TypeRef::Vec(_) | TypeRef::Map(_, _) => {
711                out.push_str(&crate::template_env::render(
712                    "named_param_teardown_hglobal.jinja",
713                    minijinja::context! { indent => "        ", handle_var => &handle_var },
714                ));
715            }
716            TypeRef::Bytes => {
717                out.push_str(&crate::template_env::render(
718                    "named_param_teardown_gchandle.jinja",
719                    minijinja::context! { indent => "        ", handle_var => &handle_var },
720                ));
721            }
722            _ => {}
723        }
724    }
725}
726
727/// Emit cleanup code with configurable indentation (used inside `Task.Run` lambdas).
728pub(super) fn emit_named_param_teardown_indented(
729    out: &mut String,
730    params: &[alef_core::ir::ParamDef],
731    indent: &str,
732    true_opaque_types: &HashSet<String>,
733) {
734    for param in params {
735        let param_name = param.name.to_lower_camel_case();
736        let handle_var = format!("{param_name}Handle");
737        match &param.ty {
738            TypeRef::Named(type_name) => {
739                if true_opaque_types.contains(type_name) {
740                    // Caller owns the opaque handle — do not free it here.
741                    continue;
742                }
743                let free_method = format!("{}Free", type_name.to_pascal_case());
744                out.push_str(&crate::template_env::render(
745                    "named_param_teardown_free.jinja",
746                    minijinja::context! { indent, free_method => &free_method, handle_var => &handle_var },
747                ));
748            }
749            TypeRef::Vec(_) | TypeRef::Map(_, _) => {
750                out.push_str(&crate::template_env::render(
751                    "named_param_teardown_hglobal.jinja",
752                    minijinja::context! { indent, handle_var => &handle_var },
753                ));
754            }
755            TypeRef::Bytes => {
756                out.push_str(&crate::template_env::render(
757                    "named_param_teardown_gchandle.jinja",
758                    minijinja::context! { indent, handle_var => &handle_var },
759                ));
760            }
761            _ => {}
762        }
763    }
764}
765
766use heck::ToLowerCamelCase;