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