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    let method = request.method.to_uppercase();
262    let fixture_id = &fixture.id;
263    let expected_status = expected.status_code;
264
265    let _ = writeln!(out, "    [Fact]");
266    let _ = writeln!(out, "    public async Task Test_{method_name}()");
267    let _ = writeln!(out, "    {{");
268    let _ = writeln!(out, "        // {description}");
269    let _ = writeln!(
270        out,
271        "        var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";"
272    );
273    let _ = writeln!(out, "        using var client = new System.Net.Http.HttpClient();");
274    let _ = writeln!(
275        out,
276        "        var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}/fixtures/{fixture_id}\");"
277    );
278
279    // Set Content-Type header if there's a body.
280    let content_type = request.content_type.as_deref().unwrap_or("application/json");
281    if request.body.is_some() {
282        let json_str = serde_json::to_string(&request.body).unwrap_or_default();
283        let escaped = escape_csharp(&json_str);
284        let _ = writeln!(
285            out,
286            "        request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");"
287        );
288    }
289
290    // Add headers (skip restricted headers).
291    const CSHARP_RESTRICTED_HEADERS: &[&str] = &[
292        "content-length",
293        "host",
294        "connection",
295        "expect",
296        "transfer-encoding",
297        "upgrade",
298    ];
299
300    for (name, value) in &request.headers {
301        if CSHARP_RESTRICTED_HEADERS.contains(&name.to_lowercase().as_str()) {
302            continue;
303        }
304        let escaped_name = escape_csharp(name);
305        let escaped_value = escape_csharp(value);
306        let _ = writeln!(
307            out,
308            "        request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");"
309        );
310    }
311
312    // Add cookies as Cookie header.
313    if !request.cookies.is_empty() {
314        let cookie_str: Vec<String> = request.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
315        let cookie_header = escape_csharp(&cookie_str.join("; "));
316        let _ = writeln!(out, "        request.Headers.Add(\"Cookie\", \"{cookie_header}\");");
317    }
318
319    let _ = writeln!(out, "        var response = await client.SendAsync(request);");
320    let _ = writeln!(
321        out,
322        "        Assert.Equal({expected_status}, (int)response.StatusCode);"
323    );
324
325    // Assert body if expected.
326    if let Some(expected_body) = &expected.body {
327        match expected_body {
328            serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
329                let json_str = serde_json::to_string(expected_body).unwrap_or_default();
330                let escaped = escape_csharp(&json_str);
331                let _ = writeln!(
332                    out,
333                    "        var bodyText = await response.Content.ReadAsStringAsync();"
334                );
335                let _ = writeln!(out, "        var body = JsonDocument.Parse(bodyText).RootElement;");
336                let _ = writeln!(
337                    out,
338                    "        var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;"
339                );
340                let _ = writeln!(
341                    out,
342                    "        Assert.Equal(expectedBody.GetRawText(), body.GetRawText());"
343                );
344            }
345            serde_json::Value::String(s) => {
346                let escaped = escape_csharp(s);
347                let _ = writeln!(
348                    out,
349                    "        var bodyText = await response.Content.ReadAsStringAsync();"
350                );
351                let _ = writeln!(out, "        Assert.Equal(\"{escaped}\", bodyText.Trim());");
352            }
353            other => {
354                let escaped = escape_csharp(&other.to_string());
355                let _ = writeln!(
356                    out,
357                    "        var bodyText = await response.Content.ReadAsStringAsync();"
358                );
359                let _ = writeln!(out, "        Assert.Equal(\"{escaped}\", bodyText.Trim());");
360            }
361        }
362    }
363
364    // Assert response headers if specified (skip special tokens).
365    for (name, value) in &expected.headers {
366        if value == "<<absent>>" || value == "<<present>>" || value == "<<uuid>>" {
367            continue;
368        }
369        // Skip content-encoding since the mock server doesn't compress.
370        if name.to_lowercase() == "content-encoding" {
371            continue;
372        }
373        let escaped_name = escape_csharp(name);
374        let escaped_value = escape_csharp(value);
375        let _ = writeln!(
376            out,
377            "        Assert.True(response.Headers.TryGetValues(\"{escaped_name}\", out var values) && values.Any(v => v.Contains(\"{escaped_value}\")), \"header {escaped_name} mismatch\");"
378        );
379    }
380
381    let _ = writeln!(out, "    }}");
382}
383
384#[allow(clippy::too_many_arguments)]
385fn render_test_method(
386    out: &mut String,
387    fixture: &Fixture,
388    class_name: &str,
389    _function_name: &str,
390    exception_class: &str,
391    _result_var: &str,
392    _args: &[crate::config::ArgMapping],
393    field_resolver: &FieldResolver,
394    result_is_simple: bool,
395    _is_async: bool,
396    e2e_config: &E2eConfig,
397    enum_fields: &HashMap<String, String>,
398) {
399    let method_name = fixture.id.to_upper_camel_case();
400    let description = &fixture.description;
401
402    // HTTP fixtures: generate real HTTP client tests using System.Net.Http.
403    if let Some(http) = &fixture.http {
404        render_http_test_method(out, fixture, http);
405        return;
406    }
407
408    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
409
410    // Resolve call config per-fixture so named calls (e.g. "parse") use the
411    // correct function name, result variable, and async flag.
412    let call_config = e2e_config.resolve_call(fixture.call.as_deref());
413    let lang = "csharp";
414    let cs_overrides = call_config.overrides.get(lang);
415    let effective_function_name = cs_overrides
416        .and_then(|o| o.function.as_ref())
417        .cloned()
418        .unwrap_or_else(|| call_config.function.to_upper_camel_case());
419    let effective_result_var = &call_config.result_var;
420    let effective_is_async = call_config.r#async;
421    let function_name = effective_function_name.as_str();
422    let result_var = effective_result_var.as_str();
423    let is_async = effective_is_async;
424    let args = call_config.args.as_slice();
425
426    // Per-call overrides: result shape, void returns, extra trailing args.
427    let per_call_result_is_simple = cs_overrides.is_some_and(|o| o.result_is_simple);
428    let effective_result_is_simple = result_is_simple || per_call_result_is_simple;
429    let returns_void = call_config.returns_void;
430    let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
431    // options_type: prefer per-call override, fall back to top-level csharp override.
432    let top_level_options_type = e2e_config
433        .call
434        .overrides
435        .get("csharp")
436        .and_then(|o| o.options_type.as_deref());
437    let effective_options_type = cs_overrides
438        .and_then(|o| o.options_type.as_deref())
439        .or(top_level_options_type);
440
441    let (mut setup_lines, args_str) = build_args_and_setup(
442        &fixture.input,
443        args,
444        class_name,
445        effective_options_type,
446        enum_fields,
447        &fixture.id,
448    );
449
450    // Build visitor if present and add to setup
451    let mut visitor_arg = String::new();
452    if let Some(visitor_spec) = &fixture.visitor {
453        visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_spec);
454    }
455
456    let args_with_visitor = if visitor_arg.is_empty() {
457        args_str
458    } else {
459        format!("{args_str}, {visitor_arg}")
460    };
461
462    let final_args = if extra_args_slice.is_empty() {
463        args_with_visitor
464    } else if args_with_visitor.is_empty() {
465        extra_args_slice.join(", ")
466    } else {
467        format!("{args_with_visitor}, {}", extra_args_slice.join(", "))
468    };
469
470    let return_type = if is_async { "async Task" } else { "void" };
471    let await_kw = if is_async { "await " } else { "" };
472
473    let _ = writeln!(out, "    [Fact]");
474    let _ = writeln!(out, "    public {return_type} Test_{method_name}()");
475    let _ = writeln!(out, "    {{");
476    let _ = writeln!(out, "        // {description}");
477
478    for line in &setup_lines {
479        let _ = writeln!(out, "        {line}");
480    }
481
482    if expects_error {
483        if is_async {
484            let _ = writeln!(
485                out,
486                "        await Assert.ThrowsAsync<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
487            );
488        } else {
489            let _ = writeln!(
490                out,
491                "        Assert.Throws<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
492            );
493        }
494        let _ = writeln!(out, "    }}");
495        return;
496    }
497
498    let result_is_vec = cs_overrides.is_some_and(|o| o.result_is_vec);
499
500    if returns_void {
501        let _ = writeln!(out, "        {await_kw}{class_name}.{function_name}({final_args});");
502    } else {
503        let _ = writeln!(
504            out,
505            "        var {result_var} = {await_kw}{class_name}.{function_name}({final_args});"
506        );
507        for assertion in &fixture.assertions {
508            render_assertion(
509                out,
510                assertion,
511                result_var,
512                class_name,
513                exception_class,
514                field_resolver,
515                effective_result_is_simple,
516                result_is_vec,
517            );
518        }
519    }
520
521    let _ = writeln!(out, "    }}");
522}
523
524/// Build setup lines (e.g. handle creation) and the argument list for the function call.
525///
526/// Returns `(setup_lines, args_string)`.
527fn build_args_and_setup(
528    input: &serde_json::Value,
529    args: &[crate::config::ArgMapping],
530    class_name: &str,
531    options_type: Option<&str>,
532    _enum_fields: &HashMap<String, String>,
533    fixture_id: &str,
534) -> (Vec<String>, String) {
535    if args.is_empty() {
536        return (Vec::new(), String::new());
537    }
538
539    let mut setup_lines: Vec<String> = Vec::new();
540    let mut parts: Vec<String> = Vec::new();
541
542    for arg in args {
543        if arg.arg_type == "bytes" {
544            // bytes args must be passed as byte[] in C#.
545            // Treat the fixture value as a UTF-8 string and convert to bytes.
546            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
547            let val = input.get(field);
548            match val {
549                None | Some(serde_json::Value::Null) if arg.optional => {
550                    parts.push("null".to_string());
551                }
552                None | Some(serde_json::Value::Null) => {
553                    parts.push("System.Array.Empty<byte>()".to_string());
554                }
555                Some(v) => {
556                    let cs_str = json_to_csharp(v);
557                    parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
558                }
559            }
560            continue;
561        }
562
563        if arg.arg_type == "mock_url" {
564            setup_lines.push(format!(
565                "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
566                arg.name,
567            ));
568            parts.push(arg.name.clone());
569            continue;
570        }
571
572        if arg.arg_type == "handle" {
573            // Generate a CreateEngine (or equivalent) call and pass the variable.
574            let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
575            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
576            let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
577            if config_value.is_null()
578                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
579            {
580                setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
581            } else {
582                // Sort discriminator fields ("type") to appear first in nested objects so
583                // System.Text.Json [JsonPolymorphic] can find the type discriminator before
584                // reading other properties (a requirement as of .NET 8).
585                let sorted = sort_discriminator_first(config_value.clone());
586                let json_str = serde_json::to_string(&sorted).unwrap_or_default();
587                let name = &arg.name;
588                setup_lines.push(format!(
589                    "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
590                    escape_csharp(&json_str),
591                ));
592                setup_lines.push(format!(
593                    "var {} = {class_name}.{constructor_name}({name}Config);",
594                    arg.name,
595                    name = name,
596                ));
597            }
598            parts.push(arg.name.clone());
599            continue;
600        }
601
602        let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
603        let val = input.get(field);
604        match val {
605            None | Some(serde_json::Value::Null) if arg.optional => {
606                // Optional arg with no fixture value: pass null explicitly since
607                // C# nullable parameters still require an argument at the call site.
608                parts.push("null".to_string());
609                continue;
610            }
611            None | Some(serde_json::Value::Null) => {
612                // Required arg with no fixture value: pass a language-appropriate default.
613                let default_val = match arg.arg_type.as_str() {
614                    "string" => "\"\"".to_string(),
615                    "int" | "integer" => "0".to_string(),
616                    "float" | "number" => "0.0d".to_string(),
617                    "bool" | "boolean" => "false".to_string(),
618                    _ => "null".to_string(),
619                };
620                parts.push(default_val);
621            }
622            Some(v) => {
623                if arg.arg_type == "json_object" {
624                    // Array value: generate a typed List<T> based on element_type.
625                    if let Some(arr) = v.as_array() {
626                        parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
627                        continue;
628                    }
629                    // Object value with known type: deserialize via JsonSerializer so the
630                    // library's own [JsonPropertyName] annotations handle field name mapping.
631                    if let Some(opts_type) = options_type {
632                        if v.is_object() {
633                            let json_str = serde_json::to_string(v).unwrap_or_default();
634                            parts.push(format!(
635                                "JsonSerializer.Deserialize<{opts_type}>(\"{}\", ConfigOptions)!",
636                                escape_csharp(&json_str),
637                            ));
638                            continue;
639                        }
640                    }
641                }
642                parts.push(json_to_csharp(v));
643            }
644        }
645    }
646
647    (setup_lines, parts.join(", "))
648}
649
650/// Convert a JSON array to a typed C# `List<T>` expression.
651///
652/// Mapping from `ArgMapping::element_type`:
653/// - `None` or any string type → `List<string>`
654/// - `"f32"` → `List<float>` with `(float)` casts
655/// - `"(String, String)"` → `List<List<string>>` for key-value pair arrays
656fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
657    match element_type {
658        Some("f32") => {
659            let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
660            format!("new List<float>() {{ {} }}", items.join(", "))
661        }
662        Some("(String, String)") => {
663            let items: Vec<String> = arr
664                .iter()
665                .map(|v| {
666                    let strs: Vec<String> = v
667                        .as_array()
668                        .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
669                    format!("new List<string>() {{ {} }}", strs.join(", "))
670                })
671                .collect();
672            format!("new List<List<string>>() {{ {} }}", items.join(", "))
673        }
674        _ => {
675            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
676            format!("new List<string>() {{ {} }}", items.join(", "))
677        }
678    }
679}
680
681#[allow(clippy::too_many_arguments)]
682fn render_assertion(
683    out: &mut String,
684    assertion: &Assertion,
685    result_var: &str,
686    class_name: &str,
687    exception_class: &str,
688    field_resolver: &FieldResolver,
689    result_is_simple: bool,
690    result_is_vec: bool,
691) {
692    // Handle synthetic / derived fields before the is_valid_for_result check
693    // so they are never treated as struct property accesses on the result.
694    if let Some(f) = &assertion.field {
695        match f.as_str() {
696            "chunks_have_content" => {
697                let pred = format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
698                match assertion.assertion_type.as_str() {
699                    "is_true" => {
700                        let _ = writeln!(out, "        Assert.True({pred});");
701                    }
702                    "is_false" => {
703                        let _ = writeln!(out, "        Assert.False({pred});");
704                    }
705                    _ => {
706                        let _ = writeln!(
707                            out,
708                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
709                        );
710                    }
711                }
712                return;
713            }
714            "chunks_have_embeddings" => {
715                let pred =
716                    format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
717                match assertion.assertion_type.as_str() {
718                    "is_true" => {
719                        let _ = writeln!(out, "        Assert.True({pred});");
720                    }
721                    "is_false" => {
722                        let _ = writeln!(out, "        Assert.False({pred});");
723                    }
724                    _ => {
725                        let _ = writeln!(
726                            out,
727                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
728                        );
729                    }
730                }
731                return;
732            }
733            // ---- EmbedResponse virtual fields ----
734            // embed_texts returns List<List<float>> in C# — no wrapper object.
735            // result_var is the embedding matrix; use it directly.
736            "embeddings" => {
737                match assertion.assertion_type.as_str() {
738                    "count_equals" => {
739                        if let Some(val) = &assertion.value {
740                            let cs_val = json_to_csharp(val);
741                            let _ = writeln!(out, "        Assert.True({result_var}.Count == {cs_val});");
742                        }
743                    }
744                    "count_min" => {
745                        if let Some(val) = &assertion.value {
746                            let cs_val = json_to_csharp(val);
747                            let _ = writeln!(out, "        Assert.True({result_var}.Count >= {cs_val});");
748                        }
749                    }
750                    "not_empty" => {
751                        let _ = writeln!(out, "        Assert.NotEmpty({result_var});");
752                    }
753                    "is_empty" => {
754                        let _ = writeln!(out, "        Assert.Empty({result_var});");
755                    }
756                    _ => {
757                        let _ = writeln!(
758                            out,
759                            "        // skipped: unsupported assertion type on synthetic field 'embeddings'"
760                        );
761                    }
762                }
763                return;
764            }
765            "embedding_dimensions" => {
766                let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
767                match assertion.assertion_type.as_str() {
768                    "equals" => {
769                        if let Some(val) = &assertion.value {
770                            let cs_val = json_to_csharp(val);
771                            let _ = writeln!(out, "        Assert.True({expr} == {cs_val});");
772                        }
773                    }
774                    "greater_than" => {
775                        if let Some(val) = &assertion.value {
776                            let cs_val = json_to_csharp(val);
777                            let _ = writeln!(out, "        Assert.True({expr} > {cs_val});");
778                        }
779                    }
780                    _ => {
781                        let _ = writeln!(
782                            out,
783                            "        // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
784                        );
785                    }
786                }
787                return;
788            }
789            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
790                let pred = match f.as_str() {
791                    "embeddings_valid" => {
792                        format!("{result_var}.All(e => e.Count > 0)")
793                    }
794                    "embeddings_finite" => {
795                        format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
796                    }
797                    "embeddings_non_zero" => {
798                        format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
799                    }
800                    "embeddings_normalized" => {
801                        format!(
802                            "{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
803                        )
804                    }
805                    _ => unreachable!(),
806                };
807                match assertion.assertion_type.as_str() {
808                    "is_true" => {
809                        let _ = writeln!(out, "        Assert.True({pred});");
810                    }
811                    "is_false" => {
812                        let _ = writeln!(out, "        Assert.False({pred});");
813                    }
814                    _ => {
815                        let _ = writeln!(
816                            out,
817                            "        // skipped: unsupported assertion type on synthetic field '{f}'"
818                        );
819                    }
820                }
821                return;
822            }
823            // ---- keywords / keywords_count ----
824            // C# ExtractionResult does not expose extracted_keywords; skip.
825            "keywords" | "keywords_count" => {
826                let _ = writeln!(
827                    out,
828                    "        // skipped: field '{f}' not available on C# ExtractionResult"
829                );
830                return;
831            }
832            _ => {}
833        }
834    }
835
836    // Skip assertions on fields that don't exist on the result type.
837    if let Some(f) = &assertion.field {
838        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
839            let _ = writeln!(out, "        // skipped: field '{f}' not available on result type");
840            return;
841        }
842    }
843
844    // When the result is a List<T>, index into the first element for field access.
845    let effective_result_var: String = if result_is_vec {
846        format!("{result_var}[0]")
847    } else {
848        result_var.to_string()
849    };
850
851    let field_expr = if result_is_simple {
852        effective_result_var.clone()
853    } else {
854        match &assertion.field {
855            Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
856            _ => effective_result_var.clone(),
857        }
858    };
859
860    match assertion.assertion_type.as_str() {
861        "equals" => {
862            if let Some(expected) = &assertion.value {
863                let cs_val = json_to_csharp(expected);
864                if expected.is_string() {
865                    // Only call .Trim() on string fields.
866                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr}.Trim());");
867                } else if expected.as_bool() == Some(true) {
868                    // Boolean true: use Assert.True to avoid xUnit2004 warning.
869                    let _ = writeln!(out, "        Assert.True({field_expr});");
870                } else if expected.as_bool() == Some(false) {
871                    // Boolean false: use Assert.False to avoid xUnit2004 warning.
872                    let _ = writeln!(out, "        Assert.False({field_expr});");
873                } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
874                    // Integer values: use Assert.True(x == n) to avoid xUnit overload
875                    // resolution ambiguity (int vs uint vs long vs DateTime).
876                    let _ = writeln!(out, "        Assert.True({field_expr} == {cs_val});");
877                } else {
878                    let _ = writeln!(out, "        Assert.Equal({cs_val}, {field_expr});");
879                }
880            }
881        }
882        "contains" => {
883            if let Some(expected) = &assertion.value {
884                // Lowercase both expected and actual so that enum fields (where .ToString()
885                // returns the PascalCase C# member name like "Anchor") correctly match
886                // fixture snake_case values like "anchor".  String fields are unaffected
887                // because lowercasing both sides preserves substring matches.
888                let lower_expected = expected.as_str().map(|s| s.to_lowercase());
889                let cs_val = lower_expected
890                    .as_deref()
891                    .map(|s| format!("\"{}\"", escape_csharp(s)))
892                    .unwrap_or_else(|| json_to_csharp(expected));
893                let _ = writeln!(
894                    out,
895                    "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
896                );
897            }
898        }
899        "contains_all" => {
900            if let Some(values) = &assertion.values {
901                for val in values {
902                    let lower_val = val.as_str().map(|s| s.to_lowercase());
903                    let cs_val = lower_val
904                        .as_deref()
905                        .map(|s| format!("\"{}\"", escape_csharp(s)))
906                        .unwrap_or_else(|| json_to_csharp(val));
907                    let _ = writeln!(
908                        out,
909                        "        Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
910                    );
911                }
912            }
913        }
914        "not_contains" => {
915            if let Some(expected) = &assertion.value {
916                let cs_val = json_to_csharp(expected);
917                let _ = writeln!(out, "        Assert.DoesNotContain({cs_val}, {field_expr}.ToString());");
918            }
919        }
920        "not_empty" => {
921            let _ = writeln!(
922                out,
923                "        Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
924            );
925        }
926        "is_empty" => {
927            let _ = writeln!(
928                out,
929                "        Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
930            );
931        }
932        "contains_any" => {
933            if let Some(values) = &assertion.values {
934                let checks: Vec<String> = values
935                    .iter()
936                    .map(|v| {
937                        let cs_val = json_to_csharp(v);
938                        format!("{field_expr}.ToString().Contains({cs_val})")
939                    })
940                    .collect();
941                let joined = checks.join(" || ");
942                let _ = writeln!(
943                    out,
944                    "        Assert.True({joined}, \"expected to contain at least one of the specified values\");"
945                );
946            }
947        }
948        "greater_than" => {
949            if let Some(val) = &assertion.value {
950                let cs_val = json_to_csharp(val);
951                let _ = writeln!(
952                    out,
953                    "        Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
954                );
955            }
956        }
957        "less_than" => {
958            if let Some(val) = &assertion.value {
959                let cs_val = json_to_csharp(val);
960                let _ = writeln!(
961                    out,
962                    "        Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
963                );
964            }
965        }
966        "greater_than_or_equal" => {
967            if let Some(val) = &assertion.value {
968                let cs_val = json_to_csharp(val);
969                let _ = writeln!(
970                    out,
971                    "        Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
972                );
973            }
974        }
975        "less_than_or_equal" => {
976            if let Some(val) = &assertion.value {
977                let cs_val = json_to_csharp(val);
978                let _ = writeln!(
979                    out,
980                    "        Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
981                );
982            }
983        }
984        "starts_with" => {
985            if let Some(expected) = &assertion.value {
986                let cs_val = json_to_csharp(expected);
987                let _ = writeln!(out, "        Assert.StartsWith({cs_val}, {field_expr});");
988            }
989        }
990        "ends_with" => {
991            if let Some(expected) = &assertion.value {
992                let cs_val = json_to_csharp(expected);
993                let _ = writeln!(out, "        Assert.EndsWith({cs_val}, {field_expr});");
994            }
995        }
996        "min_length" => {
997            if let Some(val) = &assertion.value {
998                if let Some(n) = val.as_u64() {
999                    let _ = writeln!(
1000                        out,
1001                        "        Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
1002                    );
1003                }
1004            }
1005        }
1006        "max_length" => {
1007            if let Some(val) = &assertion.value {
1008                if let Some(n) = val.as_u64() {
1009                    let _ = writeln!(
1010                        out,
1011                        "        Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
1012                    );
1013                }
1014            }
1015        }
1016        "count_min" => {
1017            if let Some(val) = &assertion.value {
1018                if let Some(n) = val.as_u64() {
1019                    let _ = writeln!(
1020                        out,
1021                        "        Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
1022                    );
1023                }
1024            }
1025        }
1026        "count_equals" => {
1027            if let Some(val) = &assertion.value {
1028                if let Some(n) = val.as_u64() {
1029                    let _ = writeln!(out, "        Assert.Equal({n}, {field_expr}.Count);");
1030                }
1031            }
1032        }
1033        "is_true" => {
1034            let _ = writeln!(out, "        Assert.True({field_expr});");
1035        }
1036        "is_false" => {
1037            let _ = writeln!(out, "        Assert.False({field_expr});");
1038        }
1039        "not_error" => {
1040            // Already handled by the call succeeding without exception.
1041        }
1042        "error" => {
1043            // Handled at the test method level.
1044        }
1045        "method_result" => {
1046            if let Some(method_name) = &assertion.method {
1047                let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
1048                let check = assertion.check.as_deref().unwrap_or("is_true");
1049                match check {
1050                    "equals" => {
1051                        if let Some(val) = &assertion.value {
1052                            if val.as_bool() == Some(true) {
1053                                let _ = writeln!(out, "        Assert.True({call_expr});");
1054                            } else if val.as_bool() == Some(false) {
1055                                let _ = writeln!(out, "        Assert.False({call_expr});");
1056                            } else {
1057                                let cs_val = json_to_csharp(val);
1058                                let _ = writeln!(out, "        Assert.Equal({cs_val}, {call_expr});");
1059                            }
1060                        }
1061                    }
1062                    "is_true" => {
1063                        let _ = writeln!(out, "        Assert.True({call_expr});");
1064                    }
1065                    "is_false" => {
1066                        let _ = writeln!(out, "        Assert.False({call_expr});");
1067                    }
1068                    "greater_than_or_equal" => {
1069                        if let Some(val) = &assertion.value {
1070                            let n = val.as_u64().unwrap_or(0);
1071                            let _ = writeln!(out, "        Assert.True({call_expr} >= {n}, \"expected >= {n}\");");
1072                        }
1073                    }
1074                    "count_min" => {
1075                        if let Some(val) = &assertion.value {
1076                            let n = val.as_u64().unwrap_or(0);
1077                            let _ = writeln!(
1078                                out,
1079                                "        Assert.True({call_expr}.Count >= {n}, \"expected at least {n} elements\");"
1080                            );
1081                        }
1082                    }
1083                    "is_error" => {
1084                        let _ = writeln!(
1085                            out,
1086                            "        Assert.Throws<{exception_class}>(() => {{ {call_expr}; }});"
1087                        );
1088                    }
1089                    "contains" => {
1090                        if let Some(val) = &assertion.value {
1091                            let cs_val = json_to_csharp(val);
1092                            let _ = writeln!(out, "        Assert.Contains({cs_val}, {call_expr});");
1093                        }
1094                    }
1095                    other_check => {
1096                        panic!("C# e2e generator: unsupported method_result check type: {other_check}");
1097                    }
1098                }
1099            } else {
1100                panic!("C# e2e generator: method_result assertion missing 'method' field");
1101            }
1102        }
1103        "matches_regex" => {
1104            if let Some(expected) = &assertion.value {
1105                let cs_val = json_to_csharp(expected);
1106                let _ = writeln!(out, "        Assert.Matches({cs_val}, {field_expr});");
1107            }
1108        }
1109        other => {
1110            panic!("C# e2e generator: unsupported assertion type: {other}");
1111        }
1112    }
1113}
1114
1115/// Recursively sort JSON objects so that any key named `"type"` appears first.
1116///
1117/// System.Text.Json's `[JsonPolymorphic]` requires the type discriminator to be
1118/// the first property when deserializing polymorphic types. Fixture config values
1119/// serialised via serde_json preserve insertion/alphabetical order, which may put
1120/// `"type"` after other keys (e.g. `"password"` before `"type"` in auth configs).
1121fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
1122    match value {
1123        serde_json::Value::Object(map) => {
1124            let mut sorted = serde_json::Map::with_capacity(map.len());
1125            // Insert "type" first if present.
1126            if let Some(type_val) = map.get("type") {
1127                sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
1128            }
1129            for (k, v) in map {
1130                if k != "type" {
1131                    sorted.insert(k, sort_discriminator_first(v));
1132                }
1133            }
1134            serde_json::Value::Object(sorted)
1135        }
1136        serde_json::Value::Array(arr) => {
1137            serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
1138        }
1139        other => other,
1140    }
1141}
1142
1143/// Convert a `serde_json::Value` to a C# literal string.
1144fn json_to_csharp(value: &serde_json::Value) -> String {
1145    match value {
1146        serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
1147        serde_json::Value::Bool(true) => "true".to_string(),
1148        serde_json::Value::Bool(false) => "false".to_string(),
1149        serde_json::Value::Number(n) => {
1150            if n.is_f64() {
1151                format!("{}d", n)
1152            } else {
1153                n.to_string()
1154            }
1155        }
1156        serde_json::Value::Null => "null".to_string(),
1157        serde_json::Value::Array(arr) => {
1158            let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
1159            format!("new[] {{ {} }}", items.join(", "))
1160        }
1161        serde_json::Value::Object(_) => {
1162            let json_str = serde_json::to_string(value).unwrap_or_default();
1163            format!("\"{}\"", escape_csharp(&json_str))
1164        }
1165    }
1166}
1167
1168// ---------------------------------------------------------------------------
1169// Visitor generation
1170// ---------------------------------------------------------------------------
1171
1172/// Build a C# visitor class and add setup lines. Returns the visitor variable name.
1173fn build_csharp_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
1174    setup_lines.push("var _testVisitor = new TestVisitor();".to_string());
1175    setup_lines.push("class TestVisitor : IVisitor".to_string());
1176    setup_lines.push("{".to_string());
1177    for (method_name, action) in &visitor_spec.callbacks {
1178        emit_csharp_visitor_method(setup_lines, method_name, action);
1179    }
1180    setup_lines.push("}".to_string());
1181    "_testVisitor".to_string()
1182}
1183
1184/// Emit a C# visitor method for a callback action.
1185fn emit_csharp_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1186    let camel_method = method_to_camel(method_name);
1187    let params = match method_name {
1188        "visit_link" => "VisitContext ctx, string href, string text, string title",
1189        "visit_image" => "VisitContext ctx, string src, string alt, string title",
1190        "visit_heading" => "VisitContext ctx, int level, string text, string id",
1191        "visit_code_block" => "VisitContext ctx, string lang, string code",
1192        "visit_code_inline"
1193        | "visit_strong"
1194        | "visit_emphasis"
1195        | "visit_strikethrough"
1196        | "visit_underline"
1197        | "visit_subscript"
1198        | "visit_superscript"
1199        | "visit_mark"
1200        | "visit_button"
1201        | "visit_summary"
1202        | "visit_figcaption"
1203        | "visit_definition_term"
1204        | "visit_definition_description" => "VisitContext ctx, string text",
1205        "visit_text" => "VisitContext ctx, string text",
1206        "visit_list_item" => "VisitContext ctx, bool ordered, string marker, string text",
1207        "visit_blockquote" => "VisitContext ctx, string content, int depth",
1208        "visit_table_row" => "VisitContext ctx, IReadOnlyList<string> cells, bool isHeader",
1209        "visit_custom_element" => "VisitContext ctx, string tagName, string html",
1210        "visit_form" => "VisitContext ctx, string actionUrl, string method",
1211        "visit_input" => "VisitContext ctx, string inputType, string name, string value",
1212        "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, string src",
1213        "visit_details" => "VisitContext ctx, bool isOpen",
1214        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
1215            "VisitContext ctx, string output"
1216        }
1217        "visit_list_start" => "VisitContext ctx, bool ordered",
1218        "visit_list_end" => "VisitContext ctx, bool ordered, string output",
1219        _ => "VisitContext ctx",
1220    };
1221
1222    setup_lines.push(format!("    public VisitResult {camel_method}({params})"));
1223    setup_lines.push("    {".to_string());
1224    match action {
1225        CallbackAction::Skip => {
1226            setup_lines.push("        return VisitResult.Skip();".to_string());
1227        }
1228        CallbackAction::Continue => {
1229            setup_lines.push("        return VisitResult.Continue();".to_string());
1230        }
1231        CallbackAction::PreserveHtml => {
1232            setup_lines.push("        return VisitResult.PreserveHtml();".to_string());
1233        }
1234        CallbackAction::Custom { output } => {
1235            let escaped = escape_csharp(output);
1236            setup_lines.push(format!("        return VisitResult.Custom(\"{escaped}\");"));
1237        }
1238        CallbackAction::CustomTemplate { template } => {
1239            let escaped = escape_csharp(template);
1240            setup_lines.push(format!("        return VisitResult.Custom($\"{escaped}\");"));
1241        }
1242    }
1243    setup_lines.push("    }".to_string());
1244}
1245
1246/// Convert snake_case method names to C# PascalCase.
1247fn method_to_camel(snake: &str) -> String {
1248    use heck::ToUpperCamelCase;
1249    snake.to_upper_camel_case()
1250}
1251
1252/// Build a C# call expression for a `method_result` assertion on a tree-sitter Tree.
1253///
1254/// Maps well-known method names to the appropriate C# static helper calls on the
1255/// generated lib class, falling back to `result_var.PascalCase()` for unknowns.
1256fn build_csharp_method_call(
1257    result_var: &str,
1258    method_name: &str,
1259    args: Option<&serde_json::Value>,
1260    class_name: &str,
1261) -> String {
1262    match method_name {
1263        "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
1264        "root_node_type" => format!("{result_var}.RootNode.Kind"),
1265        "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
1266        "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
1267        "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
1268        "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
1269        "contains_node_type" => {
1270            let node_type = args
1271                .and_then(|a| a.get("node_type"))
1272                .and_then(|v| v.as_str())
1273                .unwrap_or("");
1274            format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
1275        }
1276        "find_nodes_by_type" => {
1277            let node_type = args
1278                .and_then(|a| a.get("node_type"))
1279                .and_then(|v| v.as_str())
1280                .unwrap_or("");
1281            format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
1282        }
1283        "run_query" => {
1284            let query_source = args
1285                .and_then(|a| a.get("query_source"))
1286                .and_then(|v| v.as_str())
1287                .unwrap_or("");
1288            let language = args
1289                .and_then(|a| a.get("language"))
1290                .and_then(|v| v.as_str())
1291                .unwrap_or("");
1292            format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1293        }
1294        _ => {
1295            use heck::ToUpperCamelCase;
1296            let pascal = method_name.to_upper_camel_case();
1297            format!("{result_var}.{pascal}()")
1298        }
1299    }
1300}