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