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, HttpFixture};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use alef_core::hash::{self, CommentStyle};
13use alef_core::template_versions as tv;
14use anyhow::Result;
15use heck::ToUpperCamelCase;
16use std::collections::HashMap;
17use std::fmt::Write as FmtWrite;
18use std::path::PathBuf;
19
20use super::E2eCodegen;
21
22/// C# e2e code generator.
23pub struct CSharpCodegen;
24
25impl E2eCodegen for CSharpCodegen {
26    fn generate(
27        &self,
28        groups: &[FixtureGroup],
29        e2e_config: &E2eConfig,
30        alef_config: &AlefConfig,
31    ) -> Result<Vec<GeneratedFile>> {
32        let lang = self.language_name();
33        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
34
35        let mut files = Vec::new();
36
37        // Resolve call config with overrides.
38        let call = &e2e_config.call;
39        let overrides = call.overrides.get(lang);
40        let function_name = overrides
41            .and_then(|o| o.function.as_ref())
42            .cloned()
43            .unwrap_or_else(|| call.function.to_upper_camel_case());
44        let class_name = overrides
45            .and_then(|o| o.class.as_ref())
46            .cloned()
47            .unwrap_or_else(|| format!("{}Lib", alef_config.crate_config.name.to_upper_camel_case()));
48        // The exception class is always {CrateName}Exception, generated by the C# backend.
49        let exception_class = format!("{}Exception", alef_config.crate_config.name.to_upper_camel_case());
50        let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
51            if call.module.is_empty() {
52                "Kreuzberg".to_string()
53            } else {
54                call.module.to_upper_camel_case()
55            }
56        });
57        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
58        let result_var = &call.result_var;
59        let is_async = call.r#async;
60
61        // Resolve package config.
62        let cs_pkg = e2e_config.resolve_package("csharp");
63        let pkg_name = cs_pkg
64            .as_ref()
65            .and_then(|p| p.name.as_ref())
66            .cloned()
67            .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
68        // The project reference path uses the crate name (with hyphens) for the directory
69        // and the PascalCase name for the .csproj file.
70        let pkg_path = cs_pkg
71            .as_ref()
72            .and_then(|p| p.path.as_ref())
73            .cloned()
74            .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}.csproj"));
75        let pkg_version = cs_pkg
76            .as_ref()
77            .and_then(|p| p.version.as_ref())
78            .cloned()
79            .unwrap_or_else(|| "0.1.0".to_string());
80
81        // Generate the .csproj using a unique name derived from the package name so
82        // it does not conflict with any hand-written project files in the same directory.
83        let csproj_name = format!("{pkg_name}.E2eTests.csproj");
84        files.push(GeneratedFile {
85            path: output_base.join(&csproj_name),
86            content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
87            generated_header: false,
88        });
89
90        // Generate test files per category.
91        let tests_base = output_base.join("tests");
92        let field_resolver = FieldResolver::new(
93            &e2e_config.fields,
94            &e2e_config.fields_optional,
95            &e2e_config.result_fields,
96            &e2e_config.fields_array,
97        );
98
99        // Resolve enum_fields from C# override config.
100        static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
101        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
102
103        for group in groups {
104            let active: Vec<&Fixture> = group
105                .fixtures
106                .iter()
107                .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
108                .collect();
109
110            if active.is_empty() {
111                continue;
112            }
113
114            let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
115            let filename = format!("{test_class}.cs");
116            let content = render_test_file(
117                &group.category,
118                &active,
119                &namespace,
120                &class_name,
121                &function_name,
122                &exception_class,
123                result_var,
124                &test_class,
125                &e2e_config.call.args,
126                &field_resolver,
127                result_is_simple,
128                is_async,
129                e2e_config,
130                enum_fields,
131            );
132            files.push(GeneratedFile {
133                path: tests_base.join(filename),
134                content,
135                generated_header: true,
136            });
137        }
138
139        Ok(files)
140    }
141
142    fn language_name(&self) -> &'static str {
143        "csharp"
144    }
145}
146
147// ---------------------------------------------------------------------------
148// Rendering
149// ---------------------------------------------------------------------------
150
151fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
152    let pkg_ref = match dep_mode {
153        crate::config::DependencyMode::Registry => {
154            format!("    <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
155        }
156        crate::config::DependencyMode::Local => {
157            format!("    <ProjectReference Include=\"{pkg_path}\" />")
158        }
159    };
160    format!(
161        r#"<Project Sdk="Microsoft.NET.Sdk">
162  <PropertyGroup>
163    <TargetFramework>net10.0</TargetFramework>
164    <Nullable>enable</Nullable>
165    <ImplicitUsings>enable</ImplicitUsings>
166    <IsPackable>false</IsPackable>
167    <IsTestProject>true</IsTestProject>
168  </PropertyGroup>
169
170  <ItemGroup>
171    <PackageReference Include="Microsoft.NET.Test.Sdk" Version="{ms_test_sdk}" />
172    <PackageReference Include="xunit" Version="{xunit}" />
173    <PackageReference Include="xunit.runner.visualstudio" Version="{xunit_runner}" />
174  </ItemGroup>
175
176  <ItemGroup>
177{pkg_ref}
178  </ItemGroup>
179</Project>
180"#,
181        ms_test_sdk = tv::nuget::MICROSOFT_NET_TEST_SDK,
182        xunit = tv::nuget::XUNIT,
183        xunit_runner = tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
184    )
185}
186
187#[allow(clippy::too_many_arguments)]
188fn render_test_file(
189    category: &str,
190    fixtures: &[&Fixture],
191    namespace: &str,
192    class_name: &str,
193    function_name: &str,
194    exception_class: &str,
195    result_var: &str,
196    test_class: &str,
197    args: &[crate::config::ArgMapping],
198    field_resolver: &FieldResolver,
199    result_is_simple: bool,
200    is_async: bool,
201    e2e_config: &E2eConfig,
202    enum_fields: &HashMap<String, String>,
203) -> String {
204    let mut out = String::new();
205    out.push_str(&hash::header(CommentStyle::DoubleSlash));
206    // Always import System.Text.Json for the shared JsonOptions field.
207    let _ = writeln!(out, "using System;");
208    let _ = writeln!(out, "using System.Collections.Generic;");
209    let _ = writeln!(out, "using System.Linq;");
210    let _ = writeln!(out, "using System.Net.Http;");
211    let _ = writeln!(out, "using System.Text;");
212    let _ = writeln!(out, "using System.Text.Json;");
213    let _ = writeln!(out, "using System.Text.Json.Serialization;");
214    let _ = writeln!(out, "using System.Threading.Tasks;");
215    let _ = writeln!(out, "using Xunit;");
216    let _ = writeln!(out, "using {namespace};");
217    let _ = writeln!(out);
218    let _ = writeln!(out, "namespace Kreuzberg.E2e;");
219    let _ = writeln!(out);
220    let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
221    let _ = writeln!(out, "public class {test_class}");
222    let _ = writeln!(out, "{{");
223    // Shared options used when deserializing config JSON in test setup.
224    // Mirrors the options used by the library to ensure enum values round-trip correctly.
225    let _ = writeln!(
226        out,
227        "    private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
228    );
229    let _ = writeln!(out);
230
231    for (i, fixture) in fixtures.iter().enumerate() {
232        render_test_method(
233            &mut out,
234            fixture,
235            class_name,
236            function_name,
237            exception_class,
238            result_var,
239            args,
240            field_resolver,
241            result_is_simple,
242            is_async,
243            e2e_config,
244            enum_fields,
245        );
246        if i + 1 < fixtures.len() {
247            let _ = writeln!(out);
248        }
249    }
250
251    let _ = writeln!(out, "}}");
252    out
253}
254
255/// Render an HTTP server test method using System.Net.Http.HttpClient against MOCK_SERVER_URL.
256fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
257    let method_name = fixture.id.to_upper_camel_case();
258    let description = &fixture.description;
259    let request = &http.request;
260    let expected = &http.expected_response;
261    // C#'s System.Net.Http.HttpMethod static properties are PascalCase: Get, Post, Put, Delete, ...
262    let method = {
263        let lower = request.method.to_ascii_lowercase();
264        let mut chars = lower.chars();
265        match chars.next() {
266            Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
267            None => String::new(),
268        }
269    };
270    let fixture_id = &fixture.id;
271    let expected_status = expected.status_code;
272
273    let _ = writeln!(out, "    [Fact]");
274    let _ = writeln!(out, "    public async Task Test_{method_name}()");
275    let _ = writeln!(out, "    {{");
276    let _ = writeln!(out, "        // {description}");
277    let _ = writeln!(
278        out,
279        "        var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";"
280    );
281    let _ = writeln!(out, "        using var client = new System.Net.Http.HttpClient();");
282    let _ = writeln!(
283        out,
284        "        var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}/fixtures/{fixture_id}\");"
285    );
286
287    // Set Content-Type header if there's a body.
288    let content_type = request.content_type.as_deref().unwrap_or("application/json");
289    if request.body.is_some() {
290        let json_str = serde_json::to_string(&request.body).unwrap_or_default();
291        let escaped = escape_csharp(&json_str);
292        let _ = writeln!(
293            out,
294            "        request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");"
295        );
296    }
297
298    // Add headers (skip restricted headers).
299    const CSHARP_RESTRICTED_HEADERS: &[&str] = &[
300        "content-length",
301        "host",
302        "connection",
303        "expect",
304        "transfer-encoding",
305        "upgrade",
306    ];
307
308    for (name, value) in &request.headers {
309        if CSHARP_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
310            continue;
311        }
312        let escaped_name = escape_csharp(name);
313        let escaped_value = escape_csharp(value);
314        let _ = writeln!(
315            out,
316            "        request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");"
317        );
318    }
319
320    // Add cookies as Cookie header.
321    if !request.cookies.is_empty() {
322        let cookie_str: Vec<String> = request.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
323        let cookie_header = escape_csharp(&cookie_str.join("; "));
324        let _ = writeln!(out, "        request.Headers.Add(\"Cookie\", \"{cookie_header}\");");
325    }
326
327    let _ = writeln!(out, "        var response = await client.SendAsync(request);");
328    let _ = writeln!(
329        out,
330        "        Assert.Equal({expected_status}, (int)response.StatusCode);"
331    );
332
333    // Assert body if expected.
334    if let Some(expected_body) = &expected.body {
335        match expected_body {
336            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
337                let json_str = serde_json::to_string(expected_body).unwrap_or_default();
338                let escaped = escape_csharp(&json_str);
339                let _ = writeln!(
340                    out,
341                    "        var bodyText = await response.Content.ReadAsStringAsync();"
342                );
343                let _ = writeln!(out, "        var body = JsonDocument.Parse(bodyText).RootElement;");
344                let _ = writeln!(
345                    out,
346                    "        var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;"
347                );
348                let _ = writeln!(
349                    out,
350                    "        Assert.Equal(expectedBody.GetRawText(), body.GetRawText());"
351                );
352            }
353            serde_json::Value::String(s) => {
354                let escaped = escape_csharp(s);
355                let _ = writeln!(
356                    out,
357                    "        var bodyText = await response.Content.ReadAsStringAsync();"
358                );
359                let _ = writeln!(out, "        Assert.Equal(\"{escaped}\", bodyText.Trim());");
360            }
361            other => {
362                let escaped = escape_csharp(&other.to_string());
363                let _ = writeln!(
364                    out,
365                    "        var bodyText = await response.Content.ReadAsStringAsync();"
366                );
367                let _ = writeln!(out, "        Assert.Equal(\"{escaped}\", bodyText.Trim());");
368            }
369        }
370    }
371
372    // Assert response headers if specified (skip special tokens). Use unique
373    // variable names per assertion since `out var` introduces a new local in
374    // method scope and C# disallows redeclaration.
375    //
376    // HttpResponseMessage splits headers between `.Headers` (general HTTP
377    // headers like Cache-Control) and `.Content.Headers` (entity headers like
378    // Content-Type, Content-Length, Content-Encoding). Pick the right one or
379    // .NET throws "Misused header name".
380    let mut header_idx = 0usize;
381    for (name, value) in &expected.headers {
382        if value == "<<absent>>" || value == "<<present>>" || value == "<<uuid>>" {
383            continue;
384        }
385        // Skip content-encoding since the mock server doesn't compress.
386        if name.to_lowercase() == "content-encoding" {
387            continue;
388        }
389        let lower = name.to_ascii_lowercase();
390        let is_content_header = matches!(
391            lower.as_str(),
392            "content-type"
393                | "content-length"
394                | "content-encoding"
395                | "content-language"
396                | "content-location"
397                | "content-md5"
398                | "content-range"
399                | "content-disposition"
400                | "expires"
401                | "last-modified"
402                | "allow"
403        );
404        let target = if is_content_header {
405            "response.Content.Headers"
406        } else {
407            "response.Headers"
408        };
409        let escaped_name = escape_csharp(name);
410        let escaped_value = escape_csharp(value);
411        let var_name = format!("hdr{header_idx}");
412        header_idx += 1;
413        let _ = writeln!(
414            out,
415            "        Assert.True({target}.TryGetValues(\"{escaped_name}\", out var {var_name}) && {var_name}.Any(v => v.Contains(\"{escaped_value}\")), \"header {escaped_name} mismatch\");"
416        );
417    }
418
419    let _ = writeln!(out, "    }}");
420}
421
422#[allow(clippy::too_many_arguments)]
423fn render_test_method(
424    out: &mut String,
425    fixture: &Fixture,
426    class_name: &str,
427    _function_name: &str,
428    exception_class: &str,
429    _result_var: &str,
430    _args: &[crate::config::ArgMapping],
431    field_resolver: &FieldResolver,
432    result_is_simple: bool,
433    _is_async: bool,
434    e2e_config: &E2eConfig,
435    enum_fields: &HashMap<String, String>,
436) {
437    let method_name = fixture.id.to_upper_camel_case();
438    let description = &fixture.description;
439
440    // HTTP fixtures: generate real HTTP client tests using System.Net.Http.
441    if let Some(http) = &fixture.http {
442        render_http_test_method(out, fixture, http);
443        return;
444    }
445
446    // Non-HTTP fixtures with no mock_response: the C# binding wraps a C FFI
447    // layer and does not expose a HandleRequest callable. Emit a documented
448    // skip so the project stays compilable.
449    if fixture.mock_response.is_none() {
450        let _ = writeln!(
451            out,
452            "    [Fact(Skip = \"non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function\")]"
453        );
454        let _ = writeln!(out, "    public void Test_{method_name}()");
455        let _ = writeln!(out, "    {{");
456        let _ = writeln!(out, "        // {description}");
457        let _ = writeln!(out, "    }}");
458        return;
459    }
460
461    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
462
463    // Resolve call config per-fixture so named calls (e.g. "parse") use the
464    // correct function name, result variable, and async flag.
465    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
466    let lang = "csharp";
467    let cs_overrides = call_config.overrides.get(lang);
468    let effective_function_name = cs_overrides
469        .and_then(|o| o.function.as_ref())
470        .cloned()
471        .unwrap_or_else(|| call_config.function.to_upper_camel_case());
472    let effective_result_var = &call_config.result_var;
473    let effective_is_async = call_config.r#async;
474    let function_name = effective_function_name.as_str();
475    let result_var = effective_result_var.as_str();
476    let is_async = effective_is_async;
477    let args = call_config.args.as_slice();
478
479    // Per-call overrides: result shape, void returns, extra trailing args.
480    let per_call_result_is_simple = cs_overrides.is_some_and(|o| o.result_is_simple);
481    let effective_result_is_simple = result_is_simple || per_call_result_is_simple;
482    let returns_void = call_config.returns_void;
483    let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
484    // options_type: prefer per-call override, fall back to top-level csharp override.
485    let top_level_options_type = e2e_config
486        .call
487        .overrides
488        .get("csharp")
489        .and_then(|o| o.options_type.as_deref());
490    let effective_options_type = cs_overrides
491        .and_then(|o| o.options_type.as_deref())
492        .or(top_level_options_type);
493
494    let (mut setup_lines, args_str) = build_args_and_setup(
495        &fixture.input,
496        args,
497        class_name,
498        effective_options_type,
499        enum_fields,
500        &fixture.id,
501    );
502
503    // Build visitor if present and add to setup
504    let mut visitor_arg = String::new();
505    if let Some(visitor_spec) = &fixture.visitor {
506        visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_spec);
507    }
508
509    let args_with_visitor = if visitor_arg.is_empty() {
510        args_str
511    } else {
512        format!("{args_str}, {visitor_arg}")
513    };
514
515    let final_args = if extra_args_slice.is_empty() {
516        args_with_visitor
517    } else if args_with_visitor.is_empty() {
518        extra_args_slice.join(", ")
519    } else {
520        format!("{args_with_visitor}, {}", extra_args_slice.join(", "))
521    };
522
523    let return_type = if is_async { "async Task" } else { "void" };
524    let await_kw = if is_async { "await " } else { "" };
525
526    let _ = writeln!(out, "    [Fact]");
527    let _ = writeln!(out, "    public {return_type} Test_{method_name}()");
528    let _ = writeln!(out, "    {{");
529    let _ = writeln!(out, "        // {description}");
530
531    for line in &setup_lines {
532        let _ = writeln!(out, "        {line}");
533    }
534
535    if expects_error {
536        if is_async {
537            let _ = writeln!(
538                out,
539                "        await Assert.ThrowsAsync<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
540            );
541        } else {
542            let _ = writeln!(
543                out,
544                "        Assert.Throws<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
545            );
546        }
547        let _ = writeln!(out, "    }}");
548        return;
549    }
550
551    let result_is_vec = cs_overrides.is_some_and(|o| o.result_is_vec);
552
553    if returns_void {
554        let _ = writeln!(out, "        {await_kw}{class_name}.{function_name}({final_args});");
555    } else {
556        let _ = writeln!(
557            out,
558            "        var {result_var} = {await_kw}{class_name}.{function_name}({final_args});"
559        );
560        for assertion in &fixture.assertions {
561            render_assertion(
562                out,
563                assertion,
564                result_var,
565                class_name,
566                exception_class,
567                field_resolver,
568                effective_result_is_simple,
569                result_is_vec,
570            );
571        }
572    }
573
574    let _ = writeln!(out, "    }}");
575}
576
577/// Build setup lines (e.g. handle creation) and the argument list for the function call.
578///
579/// Returns `(setup_lines, args_string)`.
580fn build_args_and_setup(
581    input: &serde_json::Value,
582    args: &[crate::config::ArgMapping],
583    class_name: &str,
584    options_type: Option<&str>,
585    _enum_fields: &HashMap<String, String>,
586    fixture_id: &str,
587) -> (Vec<String>, String) {
588    if args.is_empty() {
589        return (Vec::new(), String::new());
590    }
591
592    let mut setup_lines: Vec<String> = Vec::new();
593    let mut parts: Vec<String> = Vec::new();
594
595    for arg in args {
596        if arg.arg_type == "bytes" {
597            // bytes args must be passed as byte[] in C#.
598            // Treat the fixture value as a UTF-8 string and convert to bytes.
599            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
600            let val = input.get(field);
601            match val {
602                None | Some(serde_json::Value::Null) if arg.optional => {
603                    parts.push("null".to_string());
604                }
605                None | Some(serde_json::Value::Null) => {
606                    parts.push("System.Array.Empty<byte>()".to_string());
607                }
608                Some(v) => {
609                    let cs_str = json_to_csharp(v);
610                    parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
611                }
612            }
613            continue;
614        }
615
616        if arg.arg_type == "mock_url" {
617            setup_lines.push(format!(
618                "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
619                arg.name,
620            ));
621            parts.push(arg.name.clone());
622            continue;
623        }
624
625        if arg.arg_type == "handle" {
626            // Generate a CreateEngine (or equivalent) call and pass the variable.
627            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
628            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
629            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
630            if config_value.is_null()
631                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
632            {
633                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
634            } else {
635                // Sort discriminator fields ("type") to appear first in nested objects so
636                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
637                // reading other properties (a requirement as of .NET 8).
638                let sorted = sort_discriminator_first(config_value.clone());
639                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
640                let name = &arg.name;
641                setup_lines.push(format!(
642                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
643                    escape_csharp(&json_str),
644                ));
645                setup_lines.push(format!(
646                    "var {} = {class_name}.{constructor_name}({name}Config);",
647                    arg.name,
648                    name = name,
649                ));
650            }
651            parts.push(arg.name.clone());
652            continue;
653        }
654
655        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
656        let val = input.get(field);
657        match val {
658            None | Some(serde_json::Value::Null) if arg.optional => {
659                // Optional arg with no fixture value: pass null explicitly since
660                // C# nullable parameters still require an argument at the call site.
661                parts.push("null".to_string());
662                continue;
663            }
664            None | Some(serde_json::Value::Null) => {
665                // Required arg with no fixture value: pass a language-appropriate default.
666                let default_val = match arg.arg_type.as_str() {
667                    "string" => "\"\"".to_string(),
668                    "int" | "integer" => "0".to_string(),
669                    "float" | "number" => "0.0d".to_string(),
670                    "bool" | "boolean" => "false".to_string(),
671                    _ => "null".to_string(),
672                };
673                parts.push(default_val);
674            }
675            Some(v) => {
676                if arg.arg_type == "json_object" {
677                    // Array value: generate a typed List<T> based on element_type.
678                    if let Some(arr) = v.as_array() {
679                        parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
680                        continue;
681                    }
682                    // Object value with known type: deserialize via JsonSerializer so the
683                    // library's own [JsonPropertyName] annotations handle field name mapping.
684                    if let Some(opts_type) = options_type {
685                        if v.is_object() {
686                            let json_str = serde_json::to_string(v).unwrap_or_default();
687                            parts.push(format!(
688                                "JsonSerializer.Deserialize<{opts_type}>(\"{}\", ConfigOptions)!",
689                                escape_csharp(&json_str),
690                            ));
691                            continue;
692                        }
693                    }
694                }
695                parts.push(json_to_csharp(v));
696            }
697        }
698    }
699
700    (setup_lines, parts.join(", "))
701}
702
703/// Convert a JSON array to a typed C# `List<T>` expression.
704///
705/// Mapping from `ArgMapping::element_type`:
706/// - `None` or any string type → `List<string>`
707/// - `"f32"` → `List<float>` with `(float)` casts
708/// - `"(String, String)"` → `List<List<string>>` for key-value pair arrays
709fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
710    match element_type {
711        Some("f32") => {
712            let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
713            format!("new List<float>() {{ {} }}", items.join(", "))
714        }
715        Some("(String, String)") => {
716            let items: Vec<String> = arr
717                .iter()
718                .map(|v| {
719                    let strs: Vec<String> = v
720                        .as_array()
721                        .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
722                    format!("new List<string>() {{ {} }}", strs.join(", "))
723                })
724                .collect();
725            format!("new List<List<string>>() {{ {} }}", items.join(", "))
726        }
727        _ => {
728            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
729            format!("new List<string>() {{ {} }}", items.join(", "))
730        }
731    }
732}
733
734#[allow(clippy::too_many_arguments)]
735fn render_assertion(
736    out: &mut String,
737    assertion: &Assertion,
738    result_var: &str,
739    class_name: &str,
740    exception_class: &str,
741    field_resolver: &FieldResolver,
742    result_is_simple: bool,
743    result_is_vec: bool,
744) {
745    // Handle synthetic / derived fields before the is_valid_for_result check
746    // so they are never treated as struct property accesses on the result.
747    if let Some(f) = &assertion.field {
748        match f.as_str() {
749            "chunks_have_content" => {
750                let pred = format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
751                match assertion.assertion_type.as_str() {
752                    "is_true" => {
753                        let _ = writeln!(out, "        Assert.True({pred});");
754                    }
755                    "is_false" => {
756                        let _ = writeln!(out, "        Assert.False({pred});");
757                    }
758                    _ => {
759                        let _ = writeln!(
760                            out,
761                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
762                        );
763                    }
764                }
765                return;
766            }
767            "chunks_have_embeddings" => {
768                let pred =
769                    format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
770                match assertion.assertion_type.as_str() {
771                    "is_true" => {
772                        let _ = writeln!(out, "        Assert.True({pred});");
773                    }
774                    "is_false" => {
775                        let _ = writeln!(out, "        Assert.False({pred});");
776                    }
777                    _ => {
778                        let _ = writeln!(
779                            out,
780                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
781                        );
782                    }
783                }
784                return;
785            }
786            // ---- EmbedResponse virtual fields ----
787            // embed_texts returns List<List<float>> in C# — no wrapper object.
788            // result_var is the embedding matrix; use it directly.
789            "embeddings" => {
790                match assertion.assertion_type.as_str() {
791                    "count_equals" => {
792                        if let Some(val) = &assertion.value {
793                            let cs_val = json_to_csharp(val);
794                            let _ = writeln!(out, "        Assert.True({result_var}.Count == {cs_val});");
795                        }
796                    }
797                    "count_min" => {
798                        if let Some(val) = &assertion.value {
799                            let cs_val = json_to_csharp(val);
800                            let _ = writeln!(out, "        Assert.True({result_var}.Count >= {cs_val});");
801                        }
802                    }
803                    "not_empty" => {
804                        let _ = writeln!(out, "        Assert.NotEmpty({result_var});");
805                    }
806                    "is_empty" => {
807                        let _ = writeln!(out, "        Assert.Empty({result_var});");
808                    }
809                    _ => {
810                        let _ = writeln!(
811                            out,
812                            "        // skipped: unsupported assertion type on synthetic field 'embeddings'"
813                        );
814                    }
815                }
816                return;
817            }
818            "embedding_dimensions" => {
819                let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
820                match assertion.assertion_type.as_str() {
821                    "equals" => {
822                        if let Some(val) = &assertion.value {
823                            let cs_val = json_to_csharp(val);
824                            let _ = writeln!(out, "        Assert.True({expr} == {cs_val});");
825                        }
826                    }
827                    "greater_than" => {
828                        if let Some(val) = &assertion.value {
829                            let cs_val = json_to_csharp(val);
830                            let _ = writeln!(out, "        Assert.True({expr} > {cs_val});");
831                        }
832                    }
833                    _ => {
834                        let _ = writeln!(
835                            out,
836                            "        // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
837                        );
838                    }
839                }
840                return;
841            }
842            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
843                let pred = match f.as_str() {
844                    "embeddings_valid" => {
845                        format!("{result_var}.All(e => e.Count > 0)")
846                    }
847                    "embeddings_finite" => {
848                        format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
849                    }
850                    "embeddings_non_zero" => {
851                        format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
852                    }
853                    "embeddings_normalized" => {
854                        format!(
855                            "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
856                        )
857                    }
858                    _ => unreachable!(),
859                };
860                match assertion.assertion_type.as_str() {
861                    "is_true" => {
862                        let _ = writeln!(out, "        Assert.True({pred});");
863                    }
864                    "is_false" => {
865                        let _ = writeln!(out, "        Assert.False({pred});");
866                    }
867                    _ => {
868                        let _ = writeln!(
869                            out,
870                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
871                        );
872                    }
873                }
874                return;
875            }
876            // ---- keywords / keywords_count ----
877            // C# ExtractionResult does not expose extracted_keywords; skip.
878            "keywords" | "keywords_count" => {
879                let _ = writeln!(
880                    out,
881                    "        // skipped: field '{f}' not available on C# ExtractionResult"
882                );
883                return;
884            }
885            _ => {}
886        }
887    }
888
889    // Skip assertions on fields that don't exist on the result type.
890    if let Some(f) = &assertion.field {
891        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
892            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
893            return;
894        }
895    }
896
897    // When the result is a List<T>, index into the first element for field access.
898    let effective_result_var: String = if result_is_vec {
899        format!("{result_var}[0]")
900    } else {
901        result_var.to_string()
902    };
903
904    let field_expr = if result_is_simple {
905        effective_result_var.clone()
906    } else {
907        match &assertion.field {
908            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
909            _ => effective_result_var.clone(),
910        }
911    };
912
913    match assertion.assertion_type.as_str() {
914        "equals" => {
915            if let Some(expected) = &assertion.value {
916                let cs_val = json_to_csharp(expected);
917                if expected.is_string() {
918                    // Only call .Trim() on string fields.
919                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr}.Trim());");
920                } else if expected.as_bool() == Some(true) {
921                    // Boolean true: use Assert.True to avoid xUnit2004 warning.
922                    let _ = writeln!(out, "        Assert.True({field_expr});");
923                } else if expected.as_bool() == Some(false) {
924                    // Boolean false: use Assert.False to avoid xUnit2004 warning.
925                    let _ = writeln!(out, "        Assert.False({field_expr});");
926                } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
927                    // Integer values: use Assert.True(x == n) to avoid xUnit overload
928                    // resolution ambiguity (int vs uint vs long vs DateTime).
929                    let _ = writeln!(out, "        Assert.True({field_expr} == {cs_val});");
930                } else {
931                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr});");
932                }
933            }
934        }
935        "contains" => {
936            if let Some(expected) = &assertion.value {
937                // Lowercase both expected and actual so that enum fields (where .ToString()
938                // returns the PascalCase C# member name like "Anchor") correctly match
939                // fixture snake_case values like "anchor".  String fields are unaffected
940                // because lowercasing both sides preserves substring matches.
941                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
942                let cs_val = lower_expected
943                    .as_deref()
944                    .map(|s| format!("\"{}\"", escape_csharp(s)))
945                    .unwrap_or_else(|| json_to_csharp(expected));
946                let _ = writeln!(
947                    out,
948                    "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
949                );
950            }
951        }
952        "contains_all" => {
953            if let Some(values) = &assertion.values {
954                for val in values {
955                    let lower_val = val.as_str().map(|s| s.to_lowercase());
956                    let cs_val = lower_val
957                        .as_deref()
958                        .map(|s| format!("\"{}\"", escape_csharp(s)))
959                        .unwrap_or_else(|| json_to_csharp(val));
960                    let _ = writeln!(
961                        out,
962                        "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
963                    );
964                }
965            }
966        }
967        "not_contains" => {
968            if let Some(expected) = &assertion.value {
969                let cs_val = json_to_csharp(expected);
970                let _ = writeln!(out, "        Assert.DoesNotContain({cs_val}, {field_expr}.ToString());");
971            }
972        }
973        "not_empty" => {
974            let _ = writeln!(
975                out,
976                "        Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
977            );
978        }
979        "is_empty" => {
980            let _ = writeln!(
981                out,
982                "        Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
983            );
984        }
985        "contains_any" => {
986            if let Some(values) = &assertion.values {
987                let checks: Vec<String> = values
988                    .iter()
989                    .map(|v| {
990                        let cs_val = json_to_csharp(v);
991                        format!("{field_expr}.ToString().Contains({cs_val})")
992                    })
993                    .collect();
994                let joined = checks.join(" || ");
995                let _ = writeln!(
996                    out,
997                    "        Assert.True({joined}, \"expected to contain at least one of the specified values\");"
998                );
999            }
1000        }
1001        "greater_than" => {
1002            if let Some(val) = &assertion.value {
1003                let cs_val = json_to_csharp(val);
1004                let _ = writeln!(
1005                    out,
1006                    "        Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
1007                );
1008            }
1009        }
1010        "less_than" => {
1011            if let Some(val) = &assertion.value {
1012                let cs_val = json_to_csharp(val);
1013                let _ = writeln!(
1014                    out,
1015                    "        Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
1016                );
1017            }
1018        }
1019        "greater_than_or_equal" => {
1020            if let Some(val) = &assertion.value {
1021                let cs_val = json_to_csharp(val);
1022                let _ = writeln!(
1023                    out,
1024                    "        Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
1025                );
1026            }
1027        }
1028        "less_than_or_equal" => {
1029            if let Some(val) = &assertion.value {
1030                let cs_val = json_to_csharp(val);
1031                let _ = writeln!(
1032                    out,
1033                    "        Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
1034                );
1035            }
1036        }
1037        "starts_with" => {
1038            if let Some(expected) = &assertion.value {
1039                let cs_val = json_to_csharp(expected);
1040                let _ = writeln!(out, "        Assert.StartsWith({cs_val}, {field_expr});");
1041            }
1042        }
1043        "ends_with" => {
1044            if let Some(expected) = &assertion.value {
1045                let cs_val = json_to_csharp(expected);
1046                let _ = writeln!(out, "        Assert.EndsWith({cs_val}, {field_expr});");
1047            }
1048        }
1049        "min_length" => {
1050            if let Some(val) = &assertion.value {
1051                if let Some(n) = val.as_u64() {
1052                    let _ = writeln!(
1053                        out,
1054                        "        Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
1055                    );
1056                }
1057            }
1058        }
1059        "max_length" => {
1060            if let Some(val) = &assertion.value {
1061                if let Some(n) = val.as_u64() {
1062                    let _ = writeln!(
1063                        out,
1064                        "        Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
1065                    );
1066                }
1067            }
1068        }
1069        "count_min" => {
1070            if let Some(val) = &assertion.value {
1071                if let Some(n) = val.as_u64() {
1072                    let _ = writeln!(
1073                        out,
1074                        "        Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
1075                    );
1076                }
1077            }
1078        }
1079        "count_equals" => {
1080            if let Some(val) = &assertion.value {
1081                if let Some(n) = val.as_u64() {
1082                    let _ = writeln!(out, "        Assert.Equal({n}, {field_expr}.Count);");
1083                }
1084            }
1085        }
1086        "is_true" => {
1087            let _ = writeln!(out, "        Assert.True({field_expr});");
1088        }
1089        "is_false" => {
1090            let _ = writeln!(out, "        Assert.False({field_expr});");
1091        }
1092        "not_error" => {
1093            // Already handled by the call succeeding without exception.
1094        }
1095        "error" => {
1096            // Handled at the test method level.
1097        }
1098        "method_result" => {
1099            if let Some(method_name) = &assertion.method {
1100                let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
1101                let check = assertion.check.as_deref().unwrap_or("is_true");
1102                match check {
1103                    "equals" => {
1104                        if let Some(val) = &assertion.value {
1105                            if val.as_bool() == Some(true) {
1106                                let _ = writeln!(out, "        Assert.True({call_expr});");
1107                            } else if val.as_bool() == Some(false) {
1108                                let _ = writeln!(out, "        Assert.False({call_expr});");
1109                            } else {
1110                                let cs_val = json_to_csharp(val);
1111                                let _ = writeln!(out, "        Assert.Equal({cs_val}, {call_expr});");
1112                            }
1113                        }
1114                    }
1115                    "is_true" => {
1116                        let _ = writeln!(out, "        Assert.True({call_expr});");
1117                    }
1118                    "is_false" => {
1119                        let _ = writeln!(out, "        Assert.False({call_expr});");
1120                    }
1121                    "greater_than_or_equal" => {
1122                        if let Some(val) = &assertion.value {
1123                            let n = val.as_u64().unwrap_or(0);
1124                            let _ = writeln!(out, "        Assert.True({call_expr} >= {n}, \"expected >= {n}\");");
1125                        }
1126                    }
1127                    "count_min" => {
1128                        if let Some(val) = &assertion.value {
1129                            let n = val.as_u64().unwrap_or(0);
1130                            let _ = writeln!(
1131                                out,
1132                                "        Assert.True({call_expr}.Count >= {n}, \"expected at least {n} elements\");"
1133                            );
1134                        }
1135                    }
1136                    "is_error" => {
1137                        let _ = writeln!(
1138                            out,
1139                            "        Assert.Throws<{exception_class}>(() => {{ {call_expr}; }});"
1140                        );
1141                    }
1142                    "contains" => {
1143                        if let Some(val) = &assertion.value {
1144                            let cs_val = json_to_csharp(val);
1145                            let _ = writeln!(out, "        Assert.Contains({cs_val}, {call_expr});");
1146                        }
1147                    }
1148                    other_check => {
1149                        panic!("C# e2e generator: unsupported method_result check type: {other_check}");
1150                    }
1151                }
1152            } else {
1153                panic!("C# e2e generator: method_result assertion missing 'method' field");
1154            }
1155        }
1156        "matches_regex" => {
1157            if let Some(expected) = &assertion.value {
1158                let cs_val = json_to_csharp(expected);
1159                let _ = writeln!(out, "        Assert.Matches({cs_val}, {field_expr});");
1160            }
1161        }
1162        other => {
1163            panic!("C# e2e generator: unsupported assertion type: {other}");
1164        }
1165    }
1166}
1167
1168/// Recursively sort JSON objects so that any key named `"type"` appears first.
1169///
1170/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
1171/// the first property when deserializing polymorphic types. Fixture config values
1172/// serialised via serde_json preserve insertion/alphabetical order, which may put
1173/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
1174fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
1175    match value {
1176        serde_json::Value::Object(map) => {
1177            let mut sorted = serde_json::Map::with_capacity(map.len());
1178            // Insert "type" first if present.
1179            if let Some(type_val) = map.get("type") {
1180                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
1181            }
1182            for (k, v) in map {
1183                if k != "type" {
1184                    sorted.insert(k, sort_discriminator_first(v));
1185                }
1186            }
1187            serde_json::Value::Object(sorted)
1188        }
1189        serde_json::Value::Array(arr) => {
1190            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
1191        }
1192        other => other,
1193    }
1194}
1195
1196/// Convert a `serde_json::Value` to a C# literal string.
1197fn json_to_csharp(value: &serde_json::Value) -> String {
1198    match value {
1199        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
1200        serde_json::Value::Bool(true) => "true".to_string(),
1201        serde_json::Value::Bool(false) => "false".to_string(),
1202        serde_json::Value::Number(n) => {
1203            if n.is_f64() {
1204                format!("{}d", n)
1205            } else {
1206                n.to_string()
1207            }
1208        }
1209        serde_json::Value::Null => "null".to_string(),
1210        serde_json::Value::Array(arr) => {
1211            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1212            format!("new[] {{ {} }}", items.join(", "))
1213        }
1214        serde_json::Value::Object(_) => {
1215            let json_str = serde_json::to_string(value).unwrap_or_default();
1216            format!("\"{}\"", escape_csharp(&json_str))
1217        }
1218    }
1219}
1220
1221// ---------------------------------------------------------------------------
1222// Visitor generation
1223// ---------------------------------------------------------------------------
1224
1225/// Build a C# visitor class and add setup lines. Returns the visitor variable name.
1226fn build_csharp_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1227    setup_lines.push("var _testVisitor = new TestVisitor();".to_string());
1228    setup_lines.push("class TestVisitor : IVisitor".to_string());
1229    setup_lines.push("{".to_string());
1230    for (method_name, action) in &visitor_spec.callbacks {
1231        emit_csharp_visitor_method(setup_lines, method_name, action);
1232    }
1233    setup_lines.push("}".to_string());
1234    "_testVisitor".to_string()
1235}
1236
1237/// Emit a C# visitor method for a callback action.
1238fn emit_csharp_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1239    let camel_method = method_to_camel(method_name);
1240    let params = match method_name {
1241        "visit_link" => "VisitContext ctx, string href, string text, string title",
1242        "visit_image" => "VisitContext ctx, string src, string alt, string title",
1243        "visit_heading" => "VisitContext ctx, int level, string text, string id",
1244        "visit_code_block" => "VisitContext ctx, string lang, string code",
1245        "visit_code_inline"
1246        | "visit_strong"
1247        | "visit_emphasis"
1248        | "visit_strikethrough"
1249        | "visit_underline"
1250        | "visit_subscript"
1251        | "visit_superscript"
1252        | "visit_mark"
1253        | "visit_button"
1254        | "visit_summary"
1255        | "visit_figcaption"
1256        | "visit_definition_term"
1257        | "visit_definition_description" => "VisitContext ctx, string text",
1258        "visit_text" => "VisitContext ctx, string text",
1259        "visit_list_item" => "VisitContext ctx, bool ordered, string marker, string text",
1260        "visit_blockquote" => "VisitContext ctx, string content, int depth",
1261        "visit_table_row" => "VisitContext ctx, IReadOnlyList<string> cells, bool isHeader",
1262        "visit_custom_element" => "VisitContext ctx, string tagName, string html",
1263        "visit_form" => "VisitContext ctx, string actionUrl, string method",
1264        "visit_input" => "VisitContext ctx, string inputType, string name, string value",
1265        "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, string src",
1266        "visit_details" => "VisitContext ctx, bool isOpen",
1267        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1268            "VisitContext ctx, string output"
1269        }
1270        "visit_list_start" => "VisitContext ctx, bool ordered",
1271        "visit_list_end" => "VisitContext ctx, bool ordered, string output",
1272        _ => "VisitContext ctx",
1273    };
1274
1275    setup_lines.push(format!("    public VisitResult {camel_method}({params})"));
1276    setup_lines.push("    {".to_string());
1277    match action {
1278        CallbackAction::Skip => {
1279            setup_lines.push("        return VisitResult.Skip();".to_string());
1280        }
1281        CallbackAction::Continue => {
1282            setup_lines.push("        return VisitResult.Continue();".to_string());
1283        }
1284        CallbackAction::PreserveHtml => {
1285            setup_lines.push("        return VisitResult.PreserveHtml();".to_string());
1286        }
1287        CallbackAction::Custom { output } => {
1288            let escaped = escape_csharp(output);
1289            setup_lines.push(format!("        return VisitResult.Custom(\"{escaped}\");"));
1290        }
1291        CallbackAction::CustomTemplate { template } => {
1292            let escaped = escape_csharp(template);
1293            setup_lines.push(format!("        return VisitResult.Custom($\"{escaped}\");"));
1294        }
1295    }
1296    setup_lines.push("    }".to_string());
1297}
1298
1299/// Convert snake_case method names to C# PascalCase.
1300fn method_to_camel(snake: &str) -> String {
1301    use heck::ToUpperCamelCase;
1302    snake.to_upper_camel_case()
1303}
1304
1305/// Build a C# call expression for a `method_result` assertion on a tree-sitter Tree.
1306///
1307/// Maps well-known method names to the appropriate C# static helper calls on the
1308/// generated lib class, falling back to `result_var.PascalCase()` for unknowns.
1309fn build_csharp_method_call(
1310    result_var: &str,
1311    method_name: &str,
1312    args: Option<&serde_json::Value>,
1313    class_name: &str,
1314) -> String {
1315    match method_name {
1316        "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
1317        "root_node_type" => format!("{result_var}.RootNode.Kind"),
1318        "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
1319        "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
1320        "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
1321        "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
1322        "contains_node_type" => {
1323            let node_type = args
1324                .and_then(|a| a.get("node_type"))
1325                .and_then(|v| v.as_str())
1326                .unwrap_or("");
1327            format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
1328        }
1329        "find_nodes_by_type" => {
1330            let node_type = args
1331                .and_then(|a| a.get("node_type"))
1332                .and_then(|v| v.as_str())
1333                .unwrap_or("");
1334            format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
1335        }
1336        "run_query" => {
1337            let query_source = args
1338                .and_then(|a| a.get("query_source"))
1339                .and_then(|v| v.as_str())
1340                .unwrap_or("");
1341            let language = args
1342                .and_then(|a| a.get("language"))
1343                .and_then(|v| v.as_str())
1344                .unwrap_or("");
1345            format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1346        }
1347        _ => {
1348            use heck::ToUpperCamelCase;
1349            let pascal = method_name.to_upper_camel_case();
1350            format!("{result_var}.{pascal}()")
1351        }
1352    }
1353}