use crate::config::E2eConfig;
use crate::escape::{escape_csharp, sanitize_filename, sanitize_ident};
use crate::field_access::FieldResolver;
use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
use alef_core::backend::GeneratedFile;
use alef_core::config::ResolvedCrateConfig;
use alef_core::hash::{self, CommentStyle};
use alef_core::template_versions as tv;
use anyhow::Result;
use heck::ToUpperCamelCase;
use std::collections::HashMap;
use std::fmt::Write as FmtWrite;
use std::path::PathBuf;
use super::E2eCodegen;
use super::client;
pub struct CSharpCodegen;
impl E2eCodegen for CSharpCodegen {
fn generate(
&self,
groups: &[FixtureGroup],
e2e_config: &E2eConfig,
config: &ResolvedCrateConfig,
) -> Result<Vec<GeneratedFile>> {
let lang = self.language_name();
let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
let mut files = Vec::new();
let call = &e2e_config.call;
let overrides = call.overrides.get(lang);
let function_name = overrides
.and_then(|o| o.function.as_ref())
.cloned()
.unwrap_or_else(|| call.function.to_upper_camel_case());
let class_name = overrides
.and_then(|o| o.class.as_ref())
.cloned()
.unwrap_or_else(|| format!("{}Lib", config.name.to_upper_camel_case()));
let exception_class = format!("{}Exception", config.name.to_upper_camel_case());
let namespace = overrides
.and_then(|o| o.module.as_ref())
.cloned()
.or_else(|| config.csharp.as_ref().and_then(|cs| cs.namespace.clone()))
.unwrap_or_else(|| {
if call.module.is_empty() {
"Kreuzberg".to_string()
} else {
call.module.to_upper_camel_case()
}
});
let result_is_simple = call.result_is_simple || overrides.is_some_and(|o| o.result_is_simple);
let result_var = &call.result_var;
let is_async = call.r#async;
let cs_pkg = e2e_config.resolve_package("csharp");
let pkg_name = cs_pkg
.as_ref()
.and_then(|p| p.name.as_ref())
.cloned()
.unwrap_or_else(|| config.name.to_upper_camel_case());
let pkg_path = cs_pkg
.as_ref()
.and_then(|p| p.path.as_ref())
.cloned()
.unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}/{pkg_name}.csproj"));
let pkg_version = cs_pkg
.as_ref()
.and_then(|p| p.version.as_ref())
.cloned()
.or_else(|| config.resolved_version())
.unwrap_or_else(|| "0.1.0".to_string());
let csproj_name = format!("{pkg_name}.E2eTests.csproj");
files.push(GeneratedFile {
path: output_base.join(&csproj_name),
content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
generated_header: false,
});
files.push(GeneratedFile {
path: output_base.join("TestSetup.cs"),
content: render_test_setup(),
generated_header: true,
});
let tests_base = output_base.join("tests");
let field_resolver = FieldResolver::new(
&e2e_config.fields,
&e2e_config.fields_optional,
&e2e_config.result_fields,
&e2e_config.fields_array,
&std::collections::HashSet::new(),
);
static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
let mut effective_nested_types = default_csharp_nested_types();
if let Some(overrides_map) = overrides.map(|o| &o.nested_types) {
effective_nested_types.extend(overrides_map.clone());
}
for group in groups {
let active: Vec<&Fixture> = group
.fixtures
.iter()
.filter(|f| super::should_include_fixture(f, lang, e2e_config))
.collect();
if active.is_empty() {
continue;
}
let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
let filename = format!("{test_class}.cs");
let content = render_test_file(
&group.category,
&active,
&namespace,
&class_name,
&function_name,
&exception_class,
result_var,
&test_class,
&e2e_config.call.args,
&field_resolver,
result_is_simple,
is_async,
e2e_config,
enum_fields,
&effective_nested_types,
);
files.push(GeneratedFile {
path: tests_base.join(filename),
content,
generated_header: true,
});
}
Ok(files)
}
fn language_name(&self) -> &'static str {
"csharp"
}
}
fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
let pkg_ref = match dep_mode {
crate::config::DependencyMode::Registry => {
format!(" <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
}
crate::config::DependencyMode::Local => {
format!(" <ProjectReference Include=\"{pkg_path}\" />")
}
};
format!(
r#"<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="{ms_test_sdk}" />
<PackageReference Include="xunit" Version="{xunit}" />
<PackageReference Include="xunit.runner.visualstudio" Version="{xunit_runner}" />
</ItemGroup>
<ItemGroup>
{pkg_ref}
</ItemGroup>
</Project>
"#,
ms_test_sdk = tv::nuget::MICROSOFT_NET_TEST_SDK,
xunit = tv::nuget::XUNIT,
xunit_runner = tv::nuget::XUNIT_RUNNER_VISUALSTUDIO,
)
}
fn render_test_setup() -> String {
let mut out = String::new();
out.push_str(&hash::header(CommentStyle::DoubleSlash));
out.push_str(
r#"using System;
using System.IO;
using System.Runtime.CompilerServices;
namespace Kreuzberg.E2eTests;
internal static class TestSetup
{
[ModuleInitializer]
internal static void Init()
{
// Walk up from the assembly directory until we find the repo root
// (the directory containing test_documents/) so that fixture paths
// like "docx/fake.docx" resolve regardless of where dotnet test
// launched the runner from.
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
var candidate = Path.Combine(dir.FullName, "test_documents");
if (Directory.Exists(candidate))
{
Directory.SetCurrentDirectory(candidate);
return;
}
dir = dir.Parent;
}
}
}
"#,
);
out
}
#[allow(clippy::too_many_arguments)]
fn render_test_file(
category: &str,
fixtures: &[&Fixture],
namespace: &str,
class_name: &str,
function_name: &str,
exception_class: &str,
result_var: &str,
test_class: &str,
args: &[crate::config::ArgMapping],
field_resolver: &FieldResolver,
result_is_simple: bool,
is_async: bool,
e2e_config: &E2eConfig,
enum_fields: &HashMap<String, String>,
nested_types: &HashMap<String, String>,
) -> String {
let mut out = String::new();
out.push_str(&hash::header(CommentStyle::DoubleSlash));
let _ = writeln!(out, "using System;");
let _ = writeln!(out, "using System.Collections.Generic;");
let _ = writeln!(out, "using System.Linq;");
let _ = writeln!(out, "using System.Net.Http;");
let _ = writeln!(out, "using System.Text;");
let _ = writeln!(out, "using System.Text.Json;");
let _ = writeln!(out, "using System.Text.Json.Serialization;");
let _ = writeln!(out, "using System.Threading.Tasks;");
let _ = writeln!(out, "using Xunit;");
let _ = writeln!(out, "using {namespace};");
let _ = writeln!(out, "using static {namespace}.{class_name};");
let _ = writeln!(out);
let _ = writeln!(out, "namespace Kreuzberg.E2e;");
let _ = writeln!(out);
let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
let _ = writeln!(out, "public class {test_class}");
let _ = writeln!(out, "{{");
let _ = writeln!(
out,
" private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
);
let _ = writeln!(out);
let mut visitor_class_decls: Vec<String> = Vec::new();
for (i, fixture) in fixtures.iter().enumerate() {
render_test_method(
&mut out,
&mut visitor_class_decls,
fixture,
class_name,
function_name,
exception_class,
result_var,
args,
field_resolver,
result_is_simple,
is_async,
e2e_config,
enum_fields,
nested_types,
);
if i + 1 < fixtures.len() {
let _ = writeln!(out);
}
}
for decl in &visitor_class_decls {
let _ = writeln!(out);
let _ = writeln!(out, "{decl}");
}
let _ = writeln!(out, "}}");
out
}
struct CSharpTestClientRenderer;
fn to_csharp_http_method(method: &str) -> String {
let lower = method.to_ascii_lowercase();
let mut chars = lower.chars();
match chars.next() {
Some(c) => c.to_ascii_uppercase().to_string() + chars.as_str(),
None => String::new(),
}
}
const CSHARP_RESTRICTED_REQUEST_HEADERS: &[&str] = &[
"content-length",
"host",
"connection",
"expect",
"transfer-encoding",
"upgrade",
"content-type",
"content-encoding",
"content-language",
"content-location",
"content-md5",
"content-range",
"content-disposition",
];
fn is_csharp_content_header(name: &str) -> bool {
matches!(
name.to_ascii_lowercase().as_str(),
"content-type"
| "content-length"
| "content-encoding"
| "content-language"
| "content-location"
| "content-md5"
| "content-range"
| "content-disposition"
| "expires"
| "last-modified"
| "allow"
)
}
impl client::TestClientRenderer for CSharpTestClientRenderer {
fn language_name(&self) -> &'static str {
"csharp"
}
fn sanitize_test_name(&self, id: &str) -> String {
id.to_upper_camel_case()
}
fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
if let Some(reason) = skip_reason {
let escaped_reason = escape_csharp(reason);
let _ = writeln!(out, " [Fact(Skip = \"{escaped_reason}\")]");
let _ = writeln!(out, " public async Task Test_{fn_name}()");
} else {
let _ = writeln!(out, " [Fact]");
let _ = writeln!(out, " public async Task Test_{fn_name}()");
}
let _ = writeln!(out, " {{");
let _ = writeln!(out, " // {description}");
}
fn render_test_close(&self, out: &mut String) {
let _ = writeln!(out, " }}");
}
fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
let method = to_csharp_http_method(ctx.method);
let path = escape_csharp(ctx.path);
let _ = writeln!(
out,
" var baseUrl = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? \"http://localhost:8080\";"
);
let _ = writeln!(
out,
" using var handler = new System.Net.Http.HttpClientHandler {{ AllowAutoRedirect = false }};"
);
let _ = writeln!(
out,
" using var client = new System.Net.Http.HttpClient(handler);"
);
let _ = writeln!(
out,
" var request = new System.Net.Http.HttpRequestMessage(System.Net.Http.HttpMethod.{method}, $\"{{baseUrl}}{path}\");"
);
if let Some(body) = ctx.body {
let content_type = ctx.content_type.unwrap_or("application/json");
let json_str = serde_json::to_string(body).unwrap_or_default();
let escaped = escape_csharp(&json_str);
let _ = writeln!(
out,
" request.Content = new System.Net.Http.StringContent(\"{escaped}\", System.Text.Encoding.UTF8, \"{content_type}\");"
);
}
for (name, value) in ctx.headers {
if CSHARP_RESTRICTED_REQUEST_HEADERS.contains(&name.to_lowercase().as_str()) {
continue;
}
let escaped_name = escape_csharp(name);
let escaped_value = escape_csharp(value);
let _ = writeln!(
out,
" request.Headers.Add(\"{escaped_name}\", \"{escaped_value}\");"
);
}
if !ctx.cookies.is_empty() {
let mut pairs: Vec<String> = ctx.cookies.iter().map(|(k, v)| format!("{k}={v}")).collect();
pairs.sort();
let cookie_header = escape_csharp(&pairs.join("; "));
let _ = writeln!(out, " request.Headers.Add(\"Cookie\", \"{cookie_header}\");");
}
let _ = writeln!(out, " var response = await client.SendAsync(request);");
}
fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
let _ = writeln!(out, " Assert.Equal({status}, (int)response.StatusCode);");
}
fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
let target = if is_csharp_content_header(name) {
"response.Content.Headers"
} else {
"response.Headers"
};
let escaped_name = escape_csharp(name);
match expected {
"<<present>>" => {
let _ = writeln!(
out,
" Assert.True({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be present\");"
);
}
"<<absent>>" => {
let _ = writeln!(
out,
" Assert.False({target}.Contains(\"{escaped_name}\"), \"expected header {escaped_name} to be absent\");"
);
}
"<<uuid>>" => {
let _ = writeln!(
out,
" Assert.True({target}.TryGetValues(\"{escaped_name}\", out var _uuidHdr) && System.Text.RegularExpressions.Regex.IsMatch(string.Join(\", \", _uuidHdr), @\"^[0-9a-fA-F]{{8}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{4}}-[0-9a-fA-F]{{12}}$\"), \"header {escaped_name} is not a UUID\");"
);
}
literal => {
let var_name = format!("hdr{}", sanitize_ident(name));
let escaped_value = escape_csharp(literal);
let _ = writeln!(
out,
" Assert.True({target}.TryGetValues(\"{escaped_name}\", out var {var_name}) && {var_name}.Any(v => v.Contains(\"{escaped_value}\")), \"header {escaped_name} mismatch\");"
);
}
}
}
fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
match expected {
serde_json::Value::Object(_) | serde_json::Value::Array(_) => {
let json_str = serde_json::to_string(expected).unwrap_or_default();
let escaped = escape_csharp(&json_str);
let _ = writeln!(
out,
" var bodyText = await response.Content.ReadAsStringAsync();"
);
let _ = writeln!(out, " var body = JsonDocument.Parse(bodyText).RootElement;");
let _ = writeln!(
out,
" var expectedBody = JsonDocument.Parse(\"{escaped}\").RootElement;"
);
let _ = writeln!(
out,
" Assert.Equal(expectedBody.GetRawText(), body.GetRawText());"
);
}
serde_json::Value::String(s) => {
let escaped = escape_csharp(s);
let _ = writeln!(
out,
" var bodyText = await response.Content.ReadAsStringAsync();"
);
let _ = writeln!(out, " Assert.Equal(\"{escaped}\", bodyText.Trim());");
}
other => {
let escaped = escape_csharp(&other.to_string());
let _ = writeln!(
out,
" var bodyText = await response.Content.ReadAsStringAsync();"
);
let _ = writeln!(out, " Assert.Equal(\"{escaped}\", bodyText.Trim());");
}
}
}
fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
if let Some(obj) = expected.as_object() {
let _ = writeln!(
out,
" var partialBodyText = await response.Content.ReadAsStringAsync();"
);
let _ = writeln!(
out,
" var partialBody = JsonDocument.Parse(partialBodyText).RootElement;"
);
for (key, val) in obj {
let escaped_key = escape_csharp(key);
let json_str = serde_json::to_string(val).unwrap_or_default();
let escaped_val = escape_csharp(&json_str);
let var_name = format!("expected{}", key.to_upper_camel_case());
let _ = writeln!(
out,
" var {var_name} = JsonDocument.Parse(\"{escaped_val}\").RootElement;"
);
let _ = writeln!(
out,
" Assert.True(partialBody.TryGetProperty(\"{escaped_key}\", out var _partialProp{var_name}) && _partialProp{var_name}.GetRawText() == {var_name}.GetRawText(), \"partial body field '{escaped_key}' mismatch\");"
);
}
}
}
fn render_assert_validation_errors(
&self,
out: &mut String,
_response_var: &str,
errors: &[ValidationErrorExpectation],
) {
let _ = writeln!(
out,
" var validationBodyText = await response.Content.ReadAsStringAsync();"
);
for err in errors {
let escaped_msg = escape_csharp(&err.msg);
let _ = writeln!(out, " Assert.Contains(\"{escaped_msg}\", validationBodyText);");
}
}
}
fn render_http_test_method(out: &mut String, fixture: &Fixture, _http: &HttpFixture) {
client::http_call::render_http_test(out, &CSharpTestClientRenderer, fixture);
}
#[allow(clippy::too_many_arguments)]
fn render_test_method(
out: &mut String,
visitor_class_decls: &mut Vec<String>,
fixture: &Fixture,
class_name: &str,
_function_name: &str,
exception_class: &str,
_result_var: &str,
_args: &[crate::config::ArgMapping],
field_resolver: &FieldResolver,
result_is_simple: bool,
_is_async: bool,
e2e_config: &E2eConfig,
enum_fields: &HashMap<String, String>,
nested_types: &HashMap<String, String>,
) {
let method_name = fixture.id.to_upper_camel_case();
let description = &fixture.description;
if let Some(http) = &fixture.http {
render_http_test_method(out, fixture, http);
return;
}
if fixture.mock_response.is_none() && !fixture_has_csharp_callable(fixture, e2e_config) {
let _ = writeln!(
out,
" [Fact(Skip = \"non-HTTP fixture: C# binding does not expose a callable for the configured `[e2e.call]` function\")]"
);
let _ = writeln!(out, " public void Test_{method_name}()");
let _ = writeln!(out, " {{");
let _ = writeln!(out, " // {description}");
let _ = writeln!(out, " }}");
return;
}
let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
let call_config = e2e_config.resolve_call(fixture.call.as_deref());
let lang = "csharp";
let cs_overrides = call_config.overrides.get(lang);
let effective_function_name = cs_overrides
.and_then(|o| o.function.as_ref())
.cloned()
.unwrap_or_else(|| call_config.function.to_upper_camel_case());
let effective_result_var = &call_config.result_var;
let effective_is_async = call_config.r#async;
let function_name = effective_function_name.as_str();
let result_var = effective_result_var.as_str();
let is_async = effective_is_async;
let args = call_config.args.as_slice();
let per_call_result_is_simple = call_config.result_is_simple || cs_overrides.is_some_and(|o| o.result_is_simple);
let effective_result_is_simple = result_is_simple || per_call_result_is_simple;
let returns_void = call_config.returns_void;
let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
let top_level_options_type = e2e_config
.call
.overrides
.get("csharp")
.and_then(|o| o.options_type.as_deref());
let effective_options_type = cs_overrides
.and_then(|o| o.options_type.as_deref())
.or(top_level_options_type);
let (mut setup_lines, args_str) = build_args_and_setup(
&fixture.input,
args,
class_name,
effective_options_type,
enum_fields,
nested_types,
&fixture.id,
);
let mut visitor_arg = String::new();
let has_visitor = fixture.visitor.is_some();
if let Some(visitor_spec) = &fixture.visitor {
visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_class_decls, &fixture.id, visitor_spec);
}
let final_args = if has_visitor && !visitor_arg.is_empty() {
let opts_type = effective_options_type.unwrap_or("ConversionOptions");
if args_str.contains("JsonSerializer.Deserialize") {
setup_lines.push(format!("var options = {args_str};"));
setup_lines.push(format!("options.Visitor = {visitor_arg};"));
"options".to_string()
} else if args_str.ends_with(", null") {
setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
let trimmed = args_str[..args_str.len() - 6].to_string(); format!("{trimmed}, options")
} else if args_str.contains(", null,") {
setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
args_str.replace(", null,", ", options,")
} else if args_str.is_empty() {
setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
"options".to_string()
} else {
setup_lines.push(format!("var options = new {opts_type} {{ Visitor = {visitor_arg} }};"));
format!("{args_str}, options")
}
} else if extra_args_slice.is_empty() {
args_str
} else if args_str.is_empty() {
extra_args_slice.join(", ")
} else {
format!("{args_str}, {}", extra_args_slice.join(", "))
};
let effective_function_name = function_name.to_string();
let return_type = if is_async { "async Task" } else { "void" };
let await_kw = if is_async { "await " } else { "" };
let client_factory = cs_overrides.and_then(|o| o.client_factory.as_deref()).or_else(|| {
e2e_config
.call
.overrides
.get("csharp")
.and_then(|o| o.client_factory.as_deref())
});
let call_target = if client_factory.is_some() {
"client".to_string()
} else {
class_name.to_string()
};
let _ = writeln!(out, " [Fact]");
let _ = writeln!(out, " public {return_type} Test_{method_name}()");
let _ = writeln!(out, " {{");
let _ = writeln!(out, " // {description}");
for line in &setup_lines {
let _ = writeln!(out, " {line}");
}
if let Some(factory) = client_factory {
let factory_name = factory.to_upper_camel_case();
let fixture_id = &fixture.id;
let _ = writeln!(
out,
" var baseUrl = (System.Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") ?? string.Empty) + \"/fixtures/{fixture_id}\";"
);
let _ = writeln!(
out,
" var client = {class_name}.{factory_name}(\"test-key\", baseUrl, null, null, null);"
);
}
if expects_error {
if is_async {
let _ = writeln!(
out,
" await Assert.ThrowsAnyAsync<{exception_class}>(() => {call_target}.{effective_function_name}({final_args}));"
);
} else {
let _ = writeln!(
out,
" Assert.ThrowsAny<{exception_class}>(() => {call_target}.{effective_function_name}({final_args}));"
);
}
let _ = writeln!(out, " }}");
return;
}
let result_is_vec = call_config.result_is_vec || cs_overrides.is_some_and(|o| o.result_is_vec);
let result_is_array = call_config.result_is_array;
if returns_void {
let _ = writeln!(
out,
" {await_kw}{call_target}.{effective_function_name}({final_args});"
);
} else {
let _ = writeln!(
out,
" var {result_var} = {await_kw}{call_target}.{effective_function_name}({final_args});"
);
for assertion in &fixture.assertions {
render_assertion(
out,
assertion,
result_var,
class_name,
exception_class,
field_resolver,
effective_result_is_simple,
result_is_vec,
result_is_array,
);
}
}
let _ = writeln!(out, " }}");
}
fn build_args_and_setup(
input: &serde_json::Value,
args: &[crate::config::ArgMapping],
class_name: &str,
options_type: Option<&str>,
enum_fields: &HashMap<String, String>,
nested_types: &HashMap<String, String>,
fixture_id: &str,
) -> (Vec<String>, String) {
if args.is_empty() {
return (Vec::new(), String::new());
}
let mut setup_lines: Vec<String> = Vec::new();
let mut parts: Vec<String> = Vec::new();
for arg in args {
if arg.arg_type == "bytes" {
let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
let val = input.get(field);
match val {
None | Some(serde_json::Value::Null) if arg.optional => {
parts.push("null".to_string());
}
None | Some(serde_json::Value::Null) => {
parts.push("System.Array.Empty<byte>()".to_string());
}
Some(v) => {
let cs_str = json_to_csharp(v);
parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
}
}
continue;
}
if arg.arg_type == "mock_url" {
setup_lines.push(format!(
"var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
arg.name,
));
parts.push(arg.name.clone());
continue;
}
if arg.arg_type == "handle" {
let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
if config_value.is_null()
|| config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
{
setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
} else {
let sorted = sort_discriminator_first(config_value.clone());
let json_str = serde_json::to_string(&sorted).unwrap_or_default();
let name = &arg.name;
setup_lines.push(format!(
"var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
escape_csharp(&json_str),
));
setup_lines.push(format!(
"var {} = {class_name}.{constructor_name}({name}Config);",
arg.name,
name = name,
));
}
parts.push(arg.name.clone());
continue;
}
let val: Option<&serde_json::Value> = if arg.field == "input" {
Some(input)
} else {
let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
input.get(field)
};
match val {
None | Some(serde_json::Value::Null) if arg.optional => {
parts.push("null".to_string());
continue;
}
None | Some(serde_json::Value::Null) => {
let default_val = match arg.arg_type.as_str() {
"string" => "\"\"".to_string(),
"int" | "integer" => "0".to_string(),
"float" | "number" => "0.0d".to_string(),
"bool" | "boolean" => "false".to_string(),
"json_object" => {
if let Some(opts_type) = options_type {
format!("new {opts_type}()")
} else {
"null".to_string()
}
}
_ => "null".to_string(),
};
parts.push(default_val);
}
Some(v) => {
if arg.arg_type == "json_object" {
if let Some(arr) = v.as_array() {
parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
continue;
}
if let Some(opts_type) = options_type {
if let Some(obj) = v.as_object() {
parts.push(csharp_object_initializer(obj, opts_type, enum_fields, nested_types));
continue;
}
}
}
parts.push(json_to_csharp(v));
}
}
}
(setup_lines, parts.join(", "))
}
fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
match element_type {
Some("BatchBytesItem") => {
let items: Vec<String> = arr
.iter()
.filter_map(|v| v.as_object())
.map(|obj| {
let content = obj.get("content").and_then(|v| v.as_array());
let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
let content_code = if let Some(arr) = content {
let bytes: Vec<String> = arr
.iter()
.filter_map(|v| v.as_u64().map(|n| format!("(byte){}", n)))
.collect();
format!("new byte[] {{ {} }}", bytes.join(", "))
} else {
"new byte[] { }".to_string()
};
format!(
"new BatchBytesItem {{ Content = {}, MimeType = \"{}\" }}",
content_code, mime_type
)
})
.collect();
format!("new List<BatchBytesItem>() {{ {} }}", items.join(", "))
}
Some("BatchFileItem") => {
let items: Vec<String> = arr
.iter()
.filter_map(|v| v.as_object())
.map(|obj| {
let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
format!("new BatchFileItem {{ Path = \"{}\" }}", path)
})
.collect();
format!("new List<BatchFileItem>() {{ {} }}", items.join(", "))
}
Some("f32") => {
let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
format!("new List<float>() {{ {} }}", items.join(", "))
}
Some("(String, String)") => {
let items: Vec<String> = arr
.iter()
.map(|v| {
let strs: Vec<String> = v
.as_array()
.map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
format!("new List<string>() {{ {} }}", strs.join(", "))
})
.collect();
format!("new List<List<string>>() {{ {} }}", items.join(", "))
}
Some(et)
if et != "f32"
&& et != "(String, String)"
&& et != "string"
&& et != "BatchBytesItem"
&& et != "BatchFileItem" =>
{
let items: Vec<String> = arr
.iter()
.map(|v| {
let json_str = serde_json::to_string(v).unwrap_or_default();
let escaped = escape_csharp(&json_str);
format!("JsonSerializer.Deserialize<{et}>(\"{escaped}\", ConfigOptions)!")
})
.collect();
format!("new List<{et}>() {{ {} }}", items.join(", "))
}
_ => {
let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
format!("new List<string>() {{ {} }}", items.join(", "))
}
}
}
#[allow(clippy::too_many_arguments)]
fn render_assertion(
out: &mut String,
assertion: &Assertion,
result_var: &str,
class_name: &str,
exception_class: &str,
field_resolver: &FieldResolver,
result_is_simple: bool,
result_is_vec: bool,
result_is_array: bool,
) {
if let Some(f) = &assertion.field {
match f.as_str() {
"chunks_have_content" => {
let pred = format!("({result_var}.Chunks ?? new()).All(c => !string.IsNullOrEmpty(c.Content))");
match assertion.assertion_type.as_str() {
"is_true" => {
let _ = writeln!(out, " Assert.True({pred});");
}
"is_false" => {
let _ = writeln!(out, " Assert.False({pred});");
}
_ => {
let _ = writeln!(
out,
" // skipped: unsupported assertion type on synthetic field '{f}'"
);
}
}
return;
}
"chunks_have_embeddings" => {
let pred =
format!("({result_var}.Chunks ?? new()).All(c => c.Embedding != null && c.Embedding.Count > 0)");
match assertion.assertion_type.as_str() {
"is_true" => {
let _ = writeln!(out, " Assert.True({pred});");
}
"is_false" => {
let _ = writeln!(out, " Assert.False({pred});");
}
_ => {
let _ = writeln!(
out,
" // skipped: unsupported assertion type on synthetic field '{f}'"
);
}
}
return;
}
"embeddings" => {
match assertion.assertion_type.as_str() {
"count_equals" => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let _ = writeln!(out, " Assert.True({result_var}.Count == {cs_val});");
}
}
"count_min" => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let _ = writeln!(out, " Assert.True({result_var}.Count >= {cs_val});");
}
}
"not_empty" => {
let _ = writeln!(out, " Assert.NotEmpty({result_var});");
}
"is_empty" => {
let _ = writeln!(out, " Assert.Empty({result_var});");
}
_ => {
let _ = writeln!(
out,
" // skipped: unsupported assertion type on synthetic field 'embeddings'"
);
}
}
return;
}
"embedding_dimensions" => {
let expr = format!("({result_var}.Count > 0 ? {result_var}[0].Count : 0)");
match assertion.assertion_type.as_str() {
"equals" => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let _ = writeln!(out, " Assert.True({expr} == {cs_val});");
}
}
"greater_than" => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let _ = writeln!(out, " Assert.True({expr} > {cs_val});");
}
}
_ => {
let _ = writeln!(
out,
" // skipped: unsupported assertion type on synthetic field 'embedding_dimensions'"
);
}
}
return;
}
"embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
let pred = match f.as_str() {
"embeddings_valid" => {
format!("{result_var}.All(e => e.Count > 0)")
}
"embeddings_finite" => {
format!("{result_var}.All(e => e.All(v => !float.IsInfinity(v) && !float.IsNaN(v)))")
}
"embeddings_non_zero" => {
format!("{result_var}.All(e => e.Any(v => v != 0.0f))")
}
"embeddings_normalized" => {
format!(
"{result_var}.All(e => {{ var n = e.Sum(v => (double)v * v); return Math.Abs(n - 1.0) < 1e-3; }})"
)
}
_ => unreachable!(),
};
match assertion.assertion_type.as_str() {
"is_true" => {
let _ = writeln!(out, " Assert.True({pred});");
}
"is_false" => {
let _ = writeln!(out, " Assert.False({pred});");
}
_ => {
let _ = writeln!(
out,
" // skipped: unsupported assertion type on synthetic field '{f}'"
);
}
}
return;
}
"keywords" | "keywords_count" => {
let _ = writeln!(
out,
" // skipped: field '{f}' not available on C# ExtractionResult"
);
return;
}
_ => {}
}
}
if let Some(f) = &assertion.field {
if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
return;
}
}
let is_count_assertion = matches!(
assertion.assertion_type.as_str(),
"count_equals" | "count_min" | "count_max"
);
let is_no_field = assertion.field.is_none() || assertion.field.as_ref().is_some_and(|f| f.is_empty());
let use_list_directly = result_is_vec && is_count_assertion && is_no_field;
let effective_result_var: String = if result_is_vec && !use_list_directly {
format!("{result_var}[0]")
} else {
result_var.to_string()
};
let field_expr = if result_is_simple {
effective_result_var.clone()
} else {
match &assertion.field {
Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
_ => effective_result_var.clone(),
}
};
let field_needs_json_serialize = if result_is_simple {
result_is_array
} else {
match &assertion.field {
Some(f) if !f.is_empty() => field_resolver.is_array(f),
_ => !result_is_simple,
}
};
let field_as_str = if field_needs_json_serialize {
format!("JsonSerializer.Serialize({field_expr})")
} else {
format!("{field_expr}.ToString()")
};
match assertion.assertion_type.as_str() {
"equals" => {
if let Some(expected) = &assertion.value {
let cs_val = json_to_csharp(expected);
if expected.is_string() {
let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr}!.Trim());");
} else if expected.as_bool() == Some(true) {
let _ = writeln!(out, " Assert.True({field_expr});");
} else if expected.as_bool() == Some(false) {
let _ = writeln!(out, " Assert.False({field_expr});");
} else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
let _ = writeln!(out, " Assert.True({field_expr} == {cs_val});");
} else {
let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr});");
}
}
}
"contains" => {
if let Some(expected) = &assertion.value {
let lower_expected = expected.as_str().map(|s| s.to_lowercase());
let cs_val = lower_expected
.as_deref()
.map(|s| format!("\"{}\"", escape_csharp(s)))
.unwrap_or_else(|| json_to_csharp(expected));
let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
}
}
"contains_all" => {
if let Some(values) = &assertion.values {
for val in values {
let lower_val = val.as_str().map(|s| s.to_lowercase());
let cs_val = lower_val
.as_deref()
.map(|s| format!("\"{}\"", escape_csharp(s)))
.unwrap_or_else(|| json_to_csharp(val));
let _ = writeln!(out, " Assert.Contains({cs_val}, {field_as_str}.ToLower());");
}
}
}
"not_contains" => {
if let Some(expected) = &assertion.value {
let cs_val = json_to_csharp(expected);
let _ = writeln!(out, " Assert.DoesNotContain({cs_val}, {field_as_str});");
}
}
"not_empty" => {
if field_needs_json_serialize {
let _ = writeln!(out, " Assert.NotEmpty({field_expr});");
} else {
let _ = writeln!(
out,
" Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
);
}
}
"is_empty" => {
if field_needs_json_serialize {
let _ = writeln!(out, " Assert.Empty({field_expr});");
} else {
let _ = writeln!(
out,
" Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
);
}
}
"contains_any" => {
if let Some(values) = &assertion.values {
let checks: Vec<String> = values
.iter()
.map(|v| {
let cs_val = json_to_csharp(v);
format!("{field_as_str}.Contains({cs_val})")
})
.collect();
let joined = checks.join(" || ");
let _ = writeln!(
out,
" Assert.True({joined}, \"expected to contain at least one of the specified values\");"
);
}
}
"greater_than" => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let _ = writeln!(
out,
" Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
);
}
}
"less_than" => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let _ = writeln!(
out,
" Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
);
}
}
"greater_than_or_equal" => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let _ = writeln!(
out,
" Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
);
}
}
"less_than_or_equal" => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let _ = writeln!(
out,
" Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
);
}
}
"starts_with" => {
if let Some(expected) = &assertion.value {
let cs_val = json_to_csharp(expected);
let _ = writeln!(out, " Assert.StartsWith({cs_val}, {field_expr});");
}
}
"ends_with" => {
if let Some(expected) = &assertion.value {
let cs_val = json_to_csharp(expected);
let _ = writeln!(out, " Assert.EndsWith({cs_val}, {field_expr});");
}
}
"min_length" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let _ = writeln!(
out,
" Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
);
}
}
}
"max_length" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let _ = writeln!(
out,
" Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
);
}
}
}
"count_min" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let _ = writeln!(
out,
" Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
);
}
}
}
"count_equals" => {
if let Some(val) = &assertion.value {
if let Some(n) = val.as_u64() {
let _ = writeln!(out, " Assert.Equal({n}, {field_expr}.Count);");
}
}
}
"is_true" => {
let _ = writeln!(out, " Assert.True({field_expr});");
}
"is_false" => {
let _ = writeln!(out, " Assert.False({field_expr});");
}
"not_error" => {
}
"error" => {
}
"method_result" => {
if let Some(method_name) = &assertion.method {
let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
let check = assertion.check.as_deref().unwrap_or("is_true");
match check {
"equals" => {
if let Some(val) = &assertion.value {
if val.as_bool() == Some(true) {
let _ = writeln!(out, " Assert.True({call_expr});");
} else if val.as_bool() == Some(false) {
let _ = writeln!(out, " Assert.False({call_expr});");
} else {
let cs_val = json_to_csharp(val);
let _ = writeln!(out, " Assert.Equal({cs_val}, {call_expr});");
}
}
}
"is_true" => {
let _ = writeln!(out, " Assert.True({call_expr});");
}
"is_false" => {
let _ = writeln!(out, " Assert.False({call_expr});");
}
"greater_than_or_equal" => {
if let Some(val) = &assertion.value {
let n = val.as_u64().unwrap_or(0);
let _ = writeln!(out, " Assert.True({call_expr} >= {n}, \"expected >= {n}\");");
}
}
"count_min" => {
if let Some(val) = &assertion.value {
let n = val.as_u64().unwrap_or(0);
let _ = writeln!(
out,
" Assert.True({call_expr}.Count >= {n}, \"expected at least {n} elements\");"
);
}
}
"is_error" => {
let _ = writeln!(
out,
" Assert.ThrowsAny<{exception_class}>(() => {{ {call_expr}; }});"
);
}
"contains" => {
if let Some(val) = &assertion.value {
let cs_val = json_to_csharp(val);
let _ = writeln!(out, " Assert.Contains({cs_val}, {call_expr});");
}
}
other_check => {
panic!("C# e2e generator: unsupported method_result check type: {other_check}");
}
}
} else {
panic!("C# e2e generator: method_result assertion missing 'method' field");
}
}
"matches_regex" => {
if let Some(expected) = &assertion.value {
let cs_val = json_to_csharp(expected);
let _ = writeln!(out, " Assert.Matches({cs_val}, {field_expr});");
}
}
other => {
panic!("C# e2e generator: unsupported assertion type: {other}");
}
}
}
fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut sorted = serde_json::Map::with_capacity(map.len());
if let Some(type_val) = map.get("type") {
sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
}
for (k, v) in map {
if k != "type" {
sorted.insert(k, sort_discriminator_first(v));
}
}
serde_json::Value::Object(sorted)
}
serde_json::Value::Array(arr) => {
serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
}
other => other,
}
}
fn json_to_csharp(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
serde_json::Value::Bool(true) => "true".to_string(),
serde_json::Value::Bool(false) => "false".to_string(),
serde_json::Value::Number(n) => {
if n.is_f64() {
format!("{}d", n)
} else {
n.to_string()
}
}
serde_json::Value::Null => "null".to_string(),
serde_json::Value::Array(arr) => {
let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
format!("new[] {{ {} }}", items.join(", "))
}
serde_json::Value::Object(_) => {
let json_str = serde_json::to_string(value).unwrap_or_default();
format!("\"{}\"", escape_csharp(&json_str))
}
}
}
fn default_csharp_nested_types() -> HashMap<String, String> {
[
("chunking", "ChunkingConfig"),
("ocr", "OcrConfig"),
("images", "ImageExtractionConfig"),
("html_output", "HtmlOutputConfig"),
("language_detection", "LanguageDetectionConfig"),
("postprocessor", "PostProcessorConfig"),
("acceleration", "AccelerationConfig"),
("email", "EmailConfig"),
("pages", "PageConfig"),
("pdf_options", "PdfConfig"),
("layout", "LayoutDetectionConfig"),
("tree_sitter", "TreeSitterConfig"),
("structured_extraction", "StructuredExtractionConfig"),
("content_filter", "ContentFilterConfig"),
("token_reduction", "TokenReductionOptions"),
("security_limits", "SecurityLimits"),
]
.iter()
.map(|(k, v)| (k.to_string(), v.to_string()))
.collect()
}
fn csharp_object_initializer(
obj: &serde_json::Map<String, serde_json::Value>,
type_name: &str,
enum_fields: &HashMap<String, String>,
nested_types: &HashMap<String, String>,
) -> String {
if obj.is_empty() {
return format!("new {type_name}()");
}
static JSON_ELEMENT_FIELDS: &[&str] = &["output_format"];
let props: Vec<String> = obj
.iter()
.map(|(key, val)| {
let pascal_key = key.to_upper_camel_case();
let cs_val = if let Some(enum_type) = enum_fields.get(key.as_str()) {
let member = val
.as_str()
.map(|s| s.to_upper_camel_case())
.unwrap_or_else(|| "null".to_string());
format!("{enum_type}.{member}")
} else if let Some(nested_type) = nested_types.get(key.as_str()) {
let normalized = normalize_csharp_enum_values(val, enum_fields);
let json_str = serde_json::to_string(&normalized).unwrap_or_default();
format!(
"JsonSerializer.Deserialize<{nested_type}>(\"{}\", ConfigOptions)!",
escape_csharp(&json_str)
)
} else if let Some(arr) = val.as_array() {
let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
format!("new List<string> {{ {} }}", items.join(", "))
} else if JSON_ELEMENT_FIELDS.contains(&key.as_str()) {
if val.is_null() {
"null".to_string()
} else {
let json_str = serde_json::to_string(val).unwrap_or_default();
format!("JsonDocument.Parse(\"{}\").RootElement", escape_csharp(&json_str))
}
} else {
json_to_csharp(val)
};
format!("{pascal_key} = {cs_val}")
})
.collect();
format!("new {} {{ {} }}", type_name, props.join(", "))
}
fn normalize_csharp_enum_values(value: &serde_json::Value, enum_fields: &HashMap<String, String>) -> serde_json::Value {
match value {
serde_json::Value::Object(map) => {
let mut result = map.clone();
for (key, val) in result.iter_mut() {
if enum_fields.contains_key(key) {
if let Some(s) = val.as_str() {
*val = serde_json::Value::String(s.to_lowercase());
}
}
}
serde_json::Value::Object(result)
}
other => other.clone(),
}
}
fn build_csharp_visitor(
setup_lines: &mut Vec<String>,
class_decls: &mut Vec<String>,
fixture_id: &str,
visitor_spec: &crate::fixture::VisitorSpec,
) -> String {
use heck::ToUpperCamelCase;
let class_name = format!("{}Visitor", fixture_id.to_upper_camel_case());
let var_name = format!("_visitor_{}", fixture_id.replace('-', "_"));
setup_lines.push(format!("var {var_name} = new {class_name}();"));
let mut decl = String::new();
let _ = writeln!(decl, " private sealed class {class_name} : IHtmlVisitor");
let _ = writeln!(decl, " {{");
let all_methods = [
"visit_element_start",
"visit_element_end",
"visit_text",
"visit_link",
"visit_image",
"visit_heading",
"visit_code_block",
"visit_code_inline",
"visit_list_item",
"visit_list_start",
"visit_list_end",
"visit_table_start",
"visit_table_row",
"visit_table_end",
"visit_blockquote",
"visit_strong",
"visit_emphasis",
"visit_strikethrough",
"visit_underline",
"visit_subscript",
"visit_superscript",
"visit_mark",
"visit_line_break",
"visit_horizontal_rule",
"visit_custom_element",
"visit_definition_list_start",
"visit_definition_term",
"visit_definition_description",
"visit_definition_list_end",
"visit_form",
"visit_input",
"visit_button",
"visit_audio",
"visit_video",
"visit_iframe",
"visit_details",
"visit_summary",
"visit_figure_start",
"visit_figcaption",
"visit_figure_end",
];
for method_name in &all_methods {
if let Some(action) = visitor_spec.callbacks.get(*method_name) {
emit_csharp_visitor_method(&mut decl, method_name, action);
} else {
emit_csharp_visitor_method(&mut decl, method_name, &CallbackAction::Continue);
}
}
let _ = writeln!(decl, " }}");
class_decls.push(decl);
var_name
}
fn emit_csharp_visitor_method(decl: &mut String, method_name: &str, action: &CallbackAction) {
let camel_method = method_to_camel(method_name);
let params = match method_name {
"visit_link" => "NodeContext ctx, string href, string text, string title",
"visit_image" => "NodeContext ctx, string src, string alt, string title",
"visit_heading" => "NodeContext ctx, uint level, string text, string id",
"visit_code_block" => "NodeContext ctx, string lang, string code",
"visit_code_inline"
| "visit_strong"
| "visit_emphasis"
| "visit_strikethrough"
| "visit_underline"
| "visit_subscript"
| "visit_superscript"
| "visit_mark"
| "visit_button"
| "visit_summary"
| "visit_figcaption"
| "visit_definition_term"
| "visit_definition_description" => "NodeContext ctx, string text",
"visit_text" => "NodeContext ctx, string text",
"visit_list_item" => "NodeContext ctx, bool ordered, string marker, string text",
"visit_blockquote" => "NodeContext ctx, string content, ulong depth",
"visit_table_row" => "NodeContext ctx, List<string> cells, bool isHeader",
"visit_custom_element" => "NodeContext ctx, string tagName, string html",
"visit_form" => "NodeContext ctx, string actionUrl, string method",
"visit_input" => "NodeContext ctx, string inputType, string name, string value",
"visit_audio" | "visit_video" | "visit_iframe" => "NodeContext ctx, string src",
"visit_details" => "NodeContext ctx, bool isOpen",
"visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
"NodeContext ctx, string output"
}
"visit_list_start" => "NodeContext ctx, bool ordered",
"visit_list_end" => "NodeContext ctx, bool ordered, string output",
"visit_element_start"
| "visit_table_start"
| "visit_definition_list_start"
| "visit_figure_start"
| "visit_line_break"
| "visit_horizontal_rule" => "NodeContext ctx",
_ => "NodeContext ctx",
};
let _ = writeln!(decl, " public VisitResult {camel_method}({params})");
let _ = writeln!(decl, " {{");
match action {
CallbackAction::Skip => {
let _ = writeln!(decl, " return new VisitResult.Skip();");
}
CallbackAction::Continue => {
let _ = writeln!(decl, " return new VisitResult.Continue();");
}
CallbackAction::PreserveHtml => {
let _ = writeln!(decl, " return new VisitResult.PreserveHtml();");
}
CallbackAction::Custom { output } => {
let escaped = escape_csharp(output);
let _ = writeln!(decl, " return new VisitResult.Custom(\"{escaped}\");");
}
CallbackAction::CustomTemplate { template } => {
let camel = snake_case_template_to_camel(template);
let escaped = escape_csharp(&camel);
let _ = writeln!(decl, " return new VisitResult.Custom($\"{escaped}\");");
}
}
let _ = writeln!(decl, " }}");
}
fn method_to_camel(snake: &str) -> String {
use heck::ToUpperCamelCase;
snake.to_upper_camel_case()
}
fn snake_case_template_to_camel(template: &str) -> String {
use heck::ToLowerCamelCase;
let mut out = String::with_capacity(template.len());
let mut chars = template.chars().peekable();
while let Some(c) = chars.next() {
if c == '{' {
let mut name = String::new();
while let Some(&nc) = chars.peek() {
if nc == '}' {
chars.next();
break;
}
name.push(nc);
chars.next();
}
out.push('{');
out.push_str(&name.to_lower_camel_case());
out.push('}');
} else {
out.push(c);
}
}
out
}
fn build_csharp_method_call(
result_var: &str,
method_name: &str,
args: Option<&serde_json::Value>,
class_name: &str,
) -> String {
match method_name {
"root_child_count" => format!("{result_var}.RootNode.ChildCount"),
"root_node_type" => format!("{result_var}.RootNode.Kind"),
"named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
"has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
"error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
"tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
"contains_node_type" => {
let node_type = args
.and_then(|a| a.get("node_type"))
.and_then(|v| v.as_str())
.unwrap_or("");
format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
}
"find_nodes_by_type" => {
let node_type = args
.and_then(|a| a.get("node_type"))
.and_then(|v| v.as_str())
.unwrap_or("");
format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
}
"run_query" => {
let query_source = args
.and_then(|a| a.get("query_source"))
.and_then(|v| v.as_str())
.unwrap_or("");
let language = args
.and_then(|a| a.get("language"))
.and_then(|v| v.as_str())
.unwrap_or("");
format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
}
_ => {
use heck::ToUpperCamelCase;
let pascal = method_name.to_upper_camel_case();
format!("{result_var}.{pascal}()")
}
}
}
fn fixture_has_csharp_callable(fixture: &Fixture, e2e_config: &E2eConfig) -> bool {
if fixture.is_http_test() {
return false;
}
let call_config = e2e_config.resolve_call(fixture.call.as_deref());
let cs_override = call_config
.overrides
.get("csharp")
.or_else(|| e2e_config.call.overrides.get("csharp"));
if cs_override.and_then(|o| o.client_factory.as_deref()).is_some() {
return true;
}
cs_override.and_then(|o| o.function.as_deref()).is_some() || !call_config.function.is_empty()
}