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