Skip to main content

alef_e2e/codegen/
csharp.rs

1//! C# e2e test generator using xUnit.
2//!
3//! Generates `e2e/csharp/E2eTests.csproj` and `tests/{Category}Tests.cs`
4//! files from JSON fixtures, driven entirely by `E2eConfig` and `CallConfig`.
5
6use crate::config::E2eConfig;
7use crate::escape::{escape_csharp, sanitize_filename};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use anyhow::Result;
13use heck::ToUpperCamelCase;
14use std::collections::HashMap;
15use std::fmt::Write as FmtWrite;
16use std::path::PathBuf;
17
18use super::E2eCodegen;
19
20/// C# e2e code generator.
21pub struct CSharpCodegen;
22
23impl E2eCodegen for CSharpCodegen {
24    fn generate(
25        &self,
26        groups: &[FixtureGroup],
27        e2e_config: &E2eConfig,
28        alef_config: &AlefConfig,
29    ) -> Result<Vec<GeneratedFile>> {
30        let lang = self.language_name();
31        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
32
33        let mut files = Vec::new();
34
35        // Resolve call config with overrides.
36        let call = &e2e_config.call;
37        let overrides = call.overrides.get(lang);
38        let function_name = overrides
39            .and_then(|o| o.function.as_ref())
40            .cloned()
41            .unwrap_or_else(|| call.function.to_upper_camel_case());
42        let class_name = overrides
43            .and_then(|o| o.class.as_ref())
44            .cloned()
45            .unwrap_or_else(|| format!("{}Lib", alef_config.crate_config.name.to_upper_camel_case()));
46        // The exception class is always {CrateName}Exception, generated by the C# backend.
47        let exception_class = format!("{}Exception", alef_config.crate_config.name.to_upper_camel_case());
48        let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
49            if call.module.is_empty() {
50                "Kreuzberg".to_string()
51            } else {
52                call.module.to_upper_camel_case()
53            }
54        });
55        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
56        let result_var = &call.result_var;
57        let is_async = call.r#async;
58
59        // Resolve package config.
60        let cs_pkg = e2e_config.resolve_package("csharp");
61        let pkg_name = cs_pkg
62            .as_ref()
63            .and_then(|p| p.name.as_ref())
64            .cloned()
65            .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
66        // The project reference path uses the crate name (with hyphens) for the directory
67        // and the PascalCase name for the .csproj file.
68        let pkg_path = cs_pkg
69            .as_ref()
70            .and_then(|p| p.path.as_ref())
71            .cloned()
72            .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}.csproj"));
73        let pkg_version = cs_pkg
74            .as_ref()
75            .and_then(|p| p.version.as_ref())
76            .cloned()
77            .unwrap_or_else(|| "0.1.0".to_string());
78
79        // Generate the .csproj using a unique name derived from the package name so
80        // it does not conflict with any hand-written project files in the same directory.
81        let csproj_name = format!("{pkg_name}.E2eTests.csproj");
82        files.push(GeneratedFile {
83            path: output_base.join(&csproj_name),
84            content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
85            generated_header: false,
86        });
87
88        // Generate test files per category.
89        let tests_base = output_base.join("tests");
90        let field_resolver = FieldResolver::new(
91            &e2e_config.fields,
92            &e2e_config.fields_optional,
93            &e2e_config.result_fields,
94            &e2e_config.fields_array,
95        );
96
97        // Resolve enum_fields from C# override config.
98        static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
99        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
100
101        for group in groups {
102            let active: Vec<&Fixture> = group
103                .fixtures
104                .iter()
105                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
106                .collect();
107
108            if active.is_empty() {
109                continue;
110            }
111
112            let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
113            let filename = format!("{test_class}.cs");
114            let content = render_test_file(
115                &group.category,
116                &active,
117                &namespace,
118                &class_name,
119                &function_name,
120                &exception_class,
121                result_var,
122                &test_class,
123                &e2e_config.call.args,
124                &field_resolver,
125                result_is_simple,
126                is_async,
127                e2e_config,
128                enum_fields,
129            );
130            files.push(GeneratedFile {
131                path: tests_base.join(filename),
132                content,
133                generated_header: true,
134            });
135        }
136
137        Ok(files)
138    }
139
140    fn language_name(&self) -> &'static str {
141        "csharp"
142    }
143}
144
145// ---------------------------------------------------------------------------
146// Rendering
147// ---------------------------------------------------------------------------
148
149fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
150    let pkg_ref = match dep_mode {
151        crate::config::DependencyMode::Registry => {
152            format!("    <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
153        }
154        crate::config::DependencyMode::Local => {
155            format!("    <ProjectReference Include=\"{pkg_path}\" />")
156        }
157    };
158    format!(
159        r#"<Project Sdk="Microsoft.NET.Sdk">
160  <PropertyGroup>
161    <TargetFramework>net10.0</TargetFramework>
162    <Nullable>enable</Nullable>
163    <ImplicitUsings>enable</ImplicitUsings>
164    <IsPackable>false</IsPackable>
165    <IsTestProject>true</IsTestProject>
166  </PropertyGroup>
167
168  <ItemGroup>
169    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
170    <PackageReference Include="xunit" Version="2.9.3" />
171    <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
172  </ItemGroup>
173
174  <ItemGroup>
175{pkg_ref}
176  </ItemGroup>
177</Project>
178"#
179    )
180}
181
182#[allow(clippy::too_many_arguments)]
183fn render_test_file(
184    category: &str,
185    fixtures: &[&Fixture],
186    namespace: &str,
187    class_name: &str,
188    function_name: &str,
189    exception_class: &str,
190    result_var: &str,
191    test_class: &str,
192    args: &[crate::config::ArgMapping],
193    field_resolver: &FieldResolver,
194    result_is_simple: bool,
195    is_async: bool,
196    e2e_config: &E2eConfig,
197    enum_fields: &HashMap<String, String>,
198) -> String {
199    let mut out = String::new();
200    let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
201    // Always import System.Text.Json for the shared JsonOptions field.
202    let _ = writeln!(out, "using System.Text.Json;");
203    let _ = writeln!(out, "using System.Text.Json.Serialization;");
204    let _ = writeln!(out, "using System.Threading.Tasks;");
205    let _ = writeln!(out, "using Xunit;");
206    let _ = writeln!(out, "using {namespace};");
207    let _ = writeln!(out);
208    let _ = writeln!(out, "namespace Kreuzberg.E2e;");
209    let _ = writeln!(out);
210    let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
211    let _ = writeln!(out, "public class {test_class}");
212    let _ = writeln!(out, "{{");
213    // Shared options used when deserializing config JSON in test setup.
214    // Mirrors the options used by the library to ensure enum values round-trip correctly.
215    let _ = writeln!(
216        out,
217        "    private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
218    );
219    let _ = writeln!(out);
220
221    for (i, fixture) in fixtures.iter().enumerate() {
222        render_test_method(
223            &mut out,
224            fixture,
225            class_name,
226            function_name,
227            exception_class,
228            result_var,
229            args,
230            field_resolver,
231            result_is_simple,
232            is_async,
233            e2e_config,
234            enum_fields,
235        );
236        if i + 1 < fixtures.len() {
237            let _ = writeln!(out);
238        }
239    }
240
241    let _ = writeln!(out, "}}");
242    out
243}
244
245#[allow(clippy::too_many_arguments)]
246fn render_test_method(
247    out: &mut String,
248    fixture: &Fixture,
249    class_name: &str,
250    _function_name: &str,
251    exception_class: &str,
252    _result_var: &str,
253    _args: &[crate::config::ArgMapping],
254    field_resolver: &FieldResolver,
255    result_is_simple: bool,
256    _is_async: bool,
257    e2e_config: &E2eConfig,
258    enum_fields: &HashMap<String, String>,
259) {
260    let method_name = fixture.id.to_upper_camel_case();
261    let description = &fixture.description;
262    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
263
264    // Resolve call config per-fixture so named calls (e.g. "parse") use the
265    // correct function name, result variable, and async flag.
266    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
267    let lang = "csharp";
268    let cs_overrides = call_config.overrides.get(lang);
269    let effective_function_name = cs_overrides
270        .and_then(|o| o.function.as_ref())
271        .cloned()
272        .unwrap_or_else(|| call_config.function.to_upper_camel_case());
273    let effective_result_var = &call_config.result_var;
274    let effective_is_async = call_config.r#async;
275    let function_name = effective_function_name.as_str();
276    let result_var = effective_result_var.as_str();
277    let is_async = effective_is_async;
278    let args = call_config.args.as_slice();
279
280    let (mut setup_lines, args_str) =
281        build_args_and_setup(&fixture.input, args, class_name, e2e_config, enum_fields, &fixture.id);
282
283    // Build visitor if present and add to setup
284    let mut visitor_arg = String::new();
285    if let Some(visitor_spec) = &fixture.visitor {
286        visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_spec);
287    }
288
289    let final_args = if visitor_arg.is_empty() {
290        args_str
291    } else {
292        format!("{args_str}, {visitor_arg}")
293    };
294
295    let return_type = if is_async { "async Task" } else { "void" };
296    let await_kw = if is_async { "await " } else { "" };
297
298    let _ = writeln!(out, "    [Fact]");
299    let _ = writeln!(out, "    public {return_type} Test_{method_name}()");
300    let _ = writeln!(out, "    {{");
301    let _ = writeln!(out, "        // {description}");
302
303    for line in &setup_lines {
304        let _ = writeln!(out, "        {line}");
305    }
306
307    if expects_error {
308        if is_async {
309            let _ = writeln!(
310                out,
311                "        await Assert.ThrowsAsync<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
312            );
313        } else {
314            let _ = writeln!(
315                out,
316                "        Assert.Throws<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
317            );
318        }
319        let _ = writeln!(out, "    }}");
320        return;
321    }
322
323    let _ = writeln!(
324        out,
325        "        var {result_var} = {await_kw}{class_name}.{function_name}({final_args});"
326    );
327
328    for assertion in &fixture.assertions {
329        render_assertion(
330            out,
331            assertion,
332            result_var,
333            class_name,
334            exception_class,
335            field_resolver,
336            result_is_simple,
337        );
338    }
339
340    let _ = writeln!(out, "    }}");
341}
342
343/// Build setup lines (e.g. handle creation) and the argument list for the function call.
344///
345/// Returns `(setup_lines, args_string)`.
346fn build_args_and_setup(
347    input: &serde_json::Value,
348    args: &[crate::config::ArgMapping],
349    class_name: &str,
350    e2e_config: &E2eConfig,
351    enum_fields: &HashMap<String, String>,
352    fixture_id: &str,
353) -> (Vec<String>, String) {
354    if args.is_empty() {
355        return (Vec::new(), json_to_csharp(input));
356    }
357
358    let overrides = e2e_config.call.overrides.get("csharp");
359    let options_type = overrides.and_then(|o| o.options_type.as_deref());
360
361    let mut setup_lines: Vec<String> = Vec::new();
362    let mut parts: Vec<String> = Vec::new();
363
364    for arg in args {
365        if arg.arg_type == "mock_url" {
366            setup_lines.push(format!(
367                "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
368                arg.name,
369            ));
370            parts.push(arg.name.clone());
371            continue;
372        }
373
374        if arg.arg_type == "handle" {
375            // Generate a CreateEngine (or equivalent) call and pass the variable.
376            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
377            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
378            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
379            if config_value.is_null()
380                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
381            {
382                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
383            } else {
384                // Sort discriminator fields ("type") to appear first in nested objects so
385                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
386                // reading other properties (a requirement as of .NET 8).
387                let sorted = sort_discriminator_first(config_value.clone());
388                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
389                let name = &arg.name;
390                setup_lines.push(format!(
391                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
392                    escape_csharp(&json_str),
393                ));
394                setup_lines.push(format!(
395                    "var {} = {class_name}.{constructor_name}({name}Config);",
396                    arg.name,
397                    name = name,
398                ));
399            }
400            parts.push(arg.name.clone());
401            continue;
402        }
403
404        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
405        let val = input.get(field);
406        match val {
407            None | Some(serde_json::Value::Null) if arg.optional => {
408                // Optional arg with no fixture value: pass null explicitly since
409                // C# nullable parameters still require an argument at the call site.
410                parts.push("null".to_string());
411                continue;
412            }
413            None | Some(serde_json::Value::Null) => {
414                // Required arg with no fixture value: pass a language-appropriate default.
415                let default_val = match arg.arg_type.as_str() {
416                    "string" => "\"\"".to_string(),
417                    "int" | "integer" => "0".to_string(),
418                    "float" | "number" => "0.0d".to_string(),
419                    "bool" | "boolean" => "false".to_string(),
420                    _ => "null".to_string(),
421                };
422                parts.push(default_val);
423            }
424            Some(v) => {
425                // For json_object args with options_type, construct a typed C# object.
426                if let (Some(opts_type), "json_object") = (options_type, arg.arg_type.as_str()) {
427                    if let Some(obj) = v.as_object() {
428                        let props: Vec<String> = obj
429                            .iter()
430                            .map(|(k, vv)| {
431                                let pascal_key = k.to_upper_camel_case();
432                                // Check if this field maps to an enum type.
433                                let cs_val = if let Some(enum_type) = enum_fields.get(k) {
434                                    // Map string value to enum constant (PascalCase).
435                                    if let Some(s) = vv.as_str() {
436                                        let pascal_val = s.to_upper_camel_case();
437                                        format!("{enum_type}.{pascal_val}")
438                                    } else {
439                                        json_to_csharp(vv)
440                                    }
441                                } else {
442                                    json_to_csharp(vv)
443                                };
444                                format!("{pascal_key} = {cs_val}")
445                            })
446                            .collect();
447                        parts.push(format!("new {opts_type} {{ {} }}", props.join(", ")));
448                        continue;
449                    }
450                }
451                parts.push(json_to_csharp(v));
452            }
453        }
454    }
455
456    (setup_lines, parts.join(", "))
457}
458
459fn render_assertion(
460    out: &mut String,
461    assertion: &Assertion,
462    result_var: &str,
463    class_name: &str,
464    exception_class: &str,
465    field_resolver: &FieldResolver,
466    result_is_simple: bool,
467) {
468    // Skip assertions on fields that don't exist on the result type.
469    if let Some(f) = &assertion.field {
470        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
471            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
472            return;
473        }
474    }
475
476    let field_expr = if result_is_simple {
477        result_var.to_string()
478    } else {
479        match &assertion.field {
480            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", result_var),
481            _ => result_var.to_string(),
482        }
483    };
484
485    // Determine whether the field resolves to an optional (nullable) type in C#.
486    let field_is_optional = assertion
487        .field
488        .as_deref()
489        .map(|f| field_resolver.is_optional(field_resolver.resolve(f)))
490        .unwrap_or(false);
491
492    match assertion.assertion_type.as_str() {
493        "equals" => {
494            if let Some(expected) = &assertion.value {
495                let cs_val = json_to_csharp(expected);
496                // Only call .Trim() on string fields, not numeric or boolean ones.
497                if expected.is_string() {
498                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr}.Trim());");
499                } else if expected.is_number() && field_is_optional {
500                    // Nullable numeric fields require an explicit cast of the expected
501                    // literal so that C# can resolve the overload (e.g. ulong?).
502                    let _ = writeln!(out, "        Assert.Equal((object?){cs_val}, (object?){field_expr});");
503                } else {
504                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr});");
505                }
506            }
507        }
508        "contains" => {
509            if let Some(expected) = &assertion.value {
510                // Lowercase both expected and actual so that enum fields (where .ToString()
511                // returns the PascalCase C# member name like "Anchor") correctly match
512                // fixture snake_case values like "anchor".  String fields are unaffected
513                // because lowercasing both sides preserves substring matches.
514                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
515                let cs_val = lower_expected
516                    .as_deref()
517                    .map(|s| format!("\"{}\"", escape_csharp(s)))
518                    .unwrap_or_else(|| json_to_csharp(expected));
519                let _ = writeln!(
520                    out,
521                    "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
522                );
523            }
524        }
525        "contains_all" => {
526            if let Some(values) = &assertion.values {
527                for val in values {
528                    let lower_val = val.as_str().map(|s| s.to_lowercase());
529                    let cs_val = lower_val
530                        .as_deref()
531                        .map(|s| format!("\"{}\"", escape_csharp(s)))
532                        .unwrap_or_else(|| json_to_csharp(val));
533                    let _ = writeln!(
534                        out,
535                        "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
536                    );
537                }
538            }
539        }
540        "not_contains" => {
541            if let Some(expected) = &assertion.value {
542                let cs_val = json_to_csharp(expected);
543                let _ = writeln!(out, "        Assert.DoesNotContain({cs_val}, {field_expr}.ToString());");
544            }
545        }
546        "not_empty" => {
547            let _ = writeln!(
548                out,
549                "        Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
550            );
551        }
552        "is_empty" => {
553            let _ = writeln!(
554                out,
555                "        Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
556            );
557        }
558        "contains_any" => {
559            if let Some(values) = &assertion.values {
560                let checks: Vec<String> = values
561                    .iter()
562                    .map(|v| {
563                        let cs_val = json_to_csharp(v);
564                        format!("{field_expr}.ToString().Contains({cs_val})")
565                    })
566                    .collect();
567                let joined = checks.join(" || ");
568                let _ = writeln!(
569                    out,
570                    "        Assert.True({joined}, \"expected to contain at least one of the specified values\");"
571                );
572            }
573        }
574        "greater_than" => {
575            if let Some(val) = &assertion.value {
576                let cs_val = json_to_csharp(val);
577                let _ = writeln!(
578                    out,
579                    "        Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
580                );
581            }
582        }
583        "less_than" => {
584            if let Some(val) = &assertion.value {
585                let cs_val = json_to_csharp(val);
586                let _ = writeln!(
587                    out,
588                    "        Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
589                );
590            }
591        }
592        "greater_than_or_equal" => {
593            if let Some(val) = &assertion.value {
594                let cs_val = json_to_csharp(val);
595                let _ = writeln!(
596                    out,
597                    "        Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
598                );
599            }
600        }
601        "less_than_or_equal" => {
602            if let Some(val) = &assertion.value {
603                let cs_val = json_to_csharp(val);
604                let _ = writeln!(
605                    out,
606                    "        Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
607                );
608            }
609        }
610        "starts_with" => {
611            if let Some(expected) = &assertion.value {
612                let cs_val = json_to_csharp(expected);
613                let _ = writeln!(out, "        Assert.StartsWith({cs_val}, {field_expr});");
614            }
615        }
616        "ends_with" => {
617            if let Some(expected) = &assertion.value {
618                let cs_val = json_to_csharp(expected);
619                let _ = writeln!(out, "        Assert.EndsWith({cs_val}, {field_expr});");
620            }
621        }
622        "min_length" => {
623            if let Some(val) = &assertion.value {
624                if let Some(n) = val.as_u64() {
625                    let _ = writeln!(
626                        out,
627                        "        Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
628                    );
629                }
630            }
631        }
632        "max_length" => {
633            if let Some(val) = &assertion.value {
634                if let Some(n) = val.as_u64() {
635                    let _ = writeln!(
636                        out,
637                        "        Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
638                    );
639                }
640            }
641        }
642        "count_min" => {
643            if let Some(val) = &assertion.value {
644                if let Some(n) = val.as_u64() {
645                    let _ = writeln!(
646                        out,
647                        "        Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
648                    );
649                }
650            }
651        }
652        "count_equals" => {
653            if let Some(val) = &assertion.value {
654                if let Some(n) = val.as_u64() {
655                    let _ = writeln!(out, "        Assert.Equal({n}, {field_expr}.Count);");
656                }
657            }
658        }
659        "is_true" => {
660            let _ = writeln!(out, "        Assert.True({field_expr});");
661        }
662        "is_false" => {
663            let _ = writeln!(out, "        Assert.False({field_expr});");
664        }
665        "not_error" => {
666            // Already handled by the call succeeding without exception.
667        }
668        "error" => {
669            // Handled at the test method level.
670        }
671        "method_result" => {
672            if let Some(method_name) = &assertion.method {
673                let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
674                let check = assertion.check.as_deref().unwrap_or("is_true");
675                match check {
676                    "equals" => {
677                        if let Some(val) = &assertion.value {
678                            if val.as_bool() == Some(true) {
679                                let _ = writeln!(out, "        Assert.True({call_expr});");
680                            } else if val.as_bool() == Some(false) {
681                                let _ = writeln!(out, "        Assert.False({call_expr});");
682                            } else {
683                                let cs_val = json_to_csharp(val);
684                                let _ = writeln!(out, "        Assert.Equal({cs_val}, {call_expr});");
685                            }
686                        }
687                    }
688                    "is_true" => {
689                        let _ = writeln!(out, "        Assert.True({call_expr});");
690                    }
691                    "is_false" => {
692                        let _ = writeln!(out, "        Assert.False({call_expr});");
693                    }
694                    "greater_than_or_equal" => {
695                        if let Some(val) = &assertion.value {
696                            let n = val.as_u64().unwrap_or(0);
697                            let _ = writeln!(out, "        Assert.True({call_expr} >= {n}, \"expected >= {n}\");");
698                        }
699                    }
700                    "count_min" => {
701                        if let Some(val) = &assertion.value {
702                            let n = val.as_u64().unwrap_or(0);
703                            let _ = writeln!(
704                                out,
705                                "        Assert.True({call_expr}.Count >= {n}, \"expected at least {n} elements\");"
706                            );
707                        }
708                    }
709                    "is_error" => {
710                        let _ = writeln!(
711                            out,
712                            "        Assert.Throws<{exception_class}>(() => {{ {call_expr}; }});"
713                        );
714                    }
715                    "contains" => {
716                        if let Some(val) = &assertion.value {
717                            let cs_val = json_to_csharp(val);
718                            let _ = writeln!(out, "        Assert.Contains({cs_val}, {call_expr});");
719                        }
720                    }
721                    other_check => {
722                        panic!("C# e2e generator: unsupported method_result check type: {other_check}");
723                    }
724                }
725            } else {
726                panic!("C# e2e generator: method_result assertion missing 'method' field");
727            }
728        }
729        other => {
730            panic!("C# e2e generator: unsupported assertion type: {other}");
731        }
732    }
733}
734
735/// Recursively sort JSON objects so that any key named `"type"` appears first.
736///
737/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
738/// the first property when deserializing polymorphic types. Fixture config values
739/// serialised via serde_json preserve insertion/alphabetical order, which may put
740/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
741fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
742    match value {
743        serde_json::Value::Object(map) => {
744            let mut sorted = serde_json::Map::with_capacity(map.len());
745            // Insert "type" first if present.
746            if let Some(type_val) = map.get("type") {
747                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
748            }
749            for (k, v) in map {
750                if k != "type" {
751                    sorted.insert(k, sort_discriminator_first(v));
752                }
753            }
754            serde_json::Value::Object(sorted)
755        }
756        serde_json::Value::Array(arr) => {
757            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
758        }
759        other => other,
760    }
761}
762
763/// Convert a `serde_json::Value` to a C# literal string.
764fn json_to_csharp(value: &serde_json::Value) -> String {
765    match value {
766        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
767        serde_json::Value::Bool(true) => "true".to_string(),
768        serde_json::Value::Bool(false) => "false".to_string(),
769        serde_json::Value::Number(n) => {
770            if n.is_f64() {
771                format!("{}d", n)
772            } else {
773                n.to_string()
774            }
775        }
776        serde_json::Value::Null => "null".to_string(),
777        serde_json::Value::Array(arr) => {
778            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
779            format!("new[] {{ {} }}", items.join(", "))
780        }
781        serde_json::Value::Object(_) => {
782            let json_str = serde_json::to_string(value).unwrap_or_default();
783            format!("\"{}\"", escape_csharp(&json_str))
784        }
785    }
786}
787
788// ---------------------------------------------------------------------------
789// Visitor generation
790// ---------------------------------------------------------------------------
791
792/// Build a C# visitor class and add setup lines. Returns the visitor variable name.
793fn build_csharp_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
794    setup_lines.push("var _testVisitor = new TestVisitor();".to_string());
795    setup_lines.push("class TestVisitor : IVisitor".to_string());
796    setup_lines.push("{".to_string());
797    for (method_name, action) in &visitor_spec.callbacks {
798        emit_csharp_visitor_method(setup_lines, method_name, action);
799    }
800    setup_lines.push("}".to_string());
801    "_testVisitor".to_string()
802}
803
804/// Emit a C# visitor method for a callback action.
805fn emit_csharp_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
806    let camel_method = method_to_camel(method_name);
807    let params = match method_name {
808        "visit_link" => "VisitContext ctx, string href, string text, string title",
809        "visit_image" => "VisitContext ctx, string src, string alt, string title",
810        "visit_heading" => "VisitContext ctx, int level, string text, string id",
811        "visit_code_block" => "VisitContext ctx, string lang, string code",
812        "visit_code_inline"
813        | "visit_strong"
814        | "visit_emphasis"
815        | "visit_strikethrough"
816        | "visit_underline"
817        | "visit_subscript"
818        | "visit_superscript"
819        | "visit_mark"
820        | "visit_button"
821        | "visit_summary"
822        | "visit_figcaption"
823        | "visit_definition_term"
824        | "visit_definition_description" => "VisitContext ctx, string text",
825        "visit_text" => "VisitContext ctx, string text",
826        "visit_list_item" => "VisitContext ctx, bool ordered, string marker, string text",
827        "visit_blockquote" => "VisitContext ctx, string content, int depth",
828        "visit_table_row" => "VisitContext ctx, IReadOnlyList<string> cells, bool isHeader",
829        "visit_custom_element" => "VisitContext ctx, string tagName, string html",
830        "visit_form" => "VisitContext ctx, string actionUrl, string method",
831        "visit_input" => "VisitContext ctx, string inputType, string name, string value",
832        "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, string src",
833        "visit_details" => "VisitContext ctx, bool isOpen",
834        _ => "VisitContext ctx",
835    };
836
837    setup_lines.push(format!("    public VisitResult {camel_method}({params})"));
838    setup_lines.push("    {".to_string());
839    match action {
840        CallbackAction::Skip => {
841            setup_lines.push("        return VisitResult.Skip();".to_string());
842        }
843        CallbackAction::Continue => {
844            setup_lines.push("        return VisitResult.Continue();".to_string());
845        }
846        CallbackAction::PreserveHtml => {
847            setup_lines.push("        return VisitResult.PreserveHtml();".to_string());
848        }
849        CallbackAction::Custom { output } => {
850            let escaped = escape_csharp(output);
851            setup_lines.push(format!("        return VisitResult.Custom(\"{escaped}\");"));
852        }
853        CallbackAction::CustomTemplate { template } => {
854            setup_lines.push(format!("        return VisitResult.Custom($\"{template}\");"));
855        }
856    }
857    setup_lines.push("    }".to_string());
858}
859
860/// Convert snake_case method names to C# PascalCase.
861fn method_to_camel(snake: &str) -> String {
862    use heck::ToUpperCamelCase;
863    snake.to_upper_camel_case()
864}
865
866/// Build a C# call expression for a `method_result` assertion on a tree-sitter Tree.
867///
868/// Maps well-known method names to the appropriate C# static helper calls on the
869/// generated lib class, falling back to `result_var.PascalCase()` for unknowns.
870fn build_csharp_method_call(
871    result_var: &str,
872    method_name: &str,
873    args: Option<&serde_json::Value>,
874    class_name: &str,
875) -> String {
876    match method_name {
877        "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
878        "root_node_type" => format!("{result_var}.RootNode.Kind"),
879        "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
880        "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
881        "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
882        "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
883        "contains_node_type" => {
884            let node_type = args
885                .and_then(|a| a.get("node_type"))
886                .and_then(|v| v.as_str())
887                .unwrap_or("");
888            format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
889        }
890        "find_nodes_by_type" => {
891            let node_type = args
892                .and_then(|a| a.get("node_type"))
893                .and_then(|v| v.as_str())
894                .unwrap_or("");
895            format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
896        }
897        "run_query" => {
898            let query_source = args
899                .and_then(|a| a.get("query_source"))
900                .and_then(|v| v.as_str())
901                .unwrap_or("");
902            let language = args
903                .and_then(|a| a.get("language"))
904                .and_then(|v| v.as_str())
905                .unwrap_or("");
906            format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
907        }
908        _ => {
909            use heck::ToUpperCamelCase;
910            let pascal = method_name.to_upper_camel_case();
911            format!("{result_var}.{pascal}()")
912        }
913    }
914}