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