Skip to main content

alef_backend_csharp/gen_bindings/
mod.rs

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