1use crate::config::E2eConfig;
7use crate::escape::{escape_csharp, sanitize_filename};
8use crate::field_access::FieldResolver;
9use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
10use alef_core::backend::GeneratedFile;
11use alef_core::config::AlefConfig;
12use 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
22pub 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 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 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 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 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 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 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 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
147fn 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 let _ = writeln!(out, "using System.Text.Json;");
208 let _ = writeln!(out, "using System.Text.Json.Serialization;");
209 let _ = writeln!(out, "using System.Threading.Tasks;");
210 let _ = writeln!(out, "using Xunit;");
211 let _ = writeln!(out, "using {namespace};");
212 let _ = writeln!(out);
213 let _ = writeln!(out, "namespace Kreuzberg.E2e;");
214 let _ = writeln!(out);
215 let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
216 let _ = writeln!(out, "public class {test_class}");
217 let _ = writeln!(out, "{{");
218 let _ = writeln!(
221 out,
222 " private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
223 );
224 let _ = writeln!(out);
225
226 for (i, fixture) in fixtures.iter().enumerate() {
227 render_test_method(
228 &mut out,
229 fixture,
230 class_name,
231 function_name,
232 exception_class,
233 result_var,
234 args,
235 field_resolver,
236 result_is_simple,
237 is_async,
238 e2e_config,
239 enum_fields,
240 );
241 if i + 1 < fixtures.len() {
242 let _ = writeln!(out);
243 }
244 }
245
246 let _ = writeln!(out, "}}");
247 out
248}
249
250#[allow(clippy::too_many_arguments)]
251fn render_test_method(
252 out: &mut String,
253 fixture: &Fixture,
254 class_name: &str,
255 _function_name: &str,
256 exception_class: &str,
257 _result_var: &str,
258 _args: &[crate::config::ArgMapping],
259 field_resolver: &FieldResolver,
260 result_is_simple: bool,
261 _is_async: bool,
262 e2e_config: &E2eConfig,
263 enum_fields: &HashMap<String, String>,
264) {
265 let method_name = fixture.id.to_upper_camel_case();
266 let description = &fixture.description;
267 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
268
269 if fixture.mock_response.is_none() {
273 let _ = writeln!(
274 out,
275 " [Fact(Skip = \"TODO: implement C# e2e tests via the spikard C# binding API\")]"
276 );
277 let _ = writeln!(out, " public void Test_{method_name}()");
278 let _ = writeln!(out, " {{");
279 let _ = writeln!(out, " // {description}");
280 let _ = writeln!(out, " }}");
281 return;
282 }
283
284 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
287 let lang = "csharp";
288 let cs_overrides = call_config.overrides.get(lang);
289 let effective_function_name = cs_overrides
290 .and_then(|o| o.function.as_ref())
291 .cloned()
292 .unwrap_or_else(|| call_config.function.to_upper_camel_case());
293 let effective_result_var = &call_config.result_var;
294 let effective_is_async = call_config.r#async;
295 let function_name = effective_function_name.as_str();
296 let result_var = effective_result_var.as_str();
297 let is_async = effective_is_async;
298 let args = call_config.args.as_slice();
299
300 let per_call_result_is_simple = cs_overrides.is_some_and(|o| o.result_is_simple);
302 let effective_result_is_simple = result_is_simple || per_call_result_is_simple;
303 let returns_void = call_config.returns_void;
304 let extra_args_slice: &[String] = cs_overrides.map_or(&[], |o| o.extra_args.as_slice());
305 let top_level_options_type = e2e_config
307 .call
308 .overrides
309 .get("csharp")
310 .and_then(|o| o.options_type.as_deref());
311 let effective_options_type = cs_overrides
312 .and_then(|o| o.options_type.as_deref())
313 .or(top_level_options_type);
314
315 let (mut setup_lines, args_str) = build_args_and_setup(
316 &fixture.input,
317 args,
318 class_name,
319 effective_options_type,
320 enum_fields,
321 &fixture.id,
322 );
323
324 let mut visitor_arg = String::new();
326 if let Some(visitor_spec) = &fixture.visitor {
327 visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_spec);
328 }
329
330 let args_with_visitor = if visitor_arg.is_empty() {
331 args_str
332 } else {
333 format!("{args_str}, {visitor_arg}")
334 };
335
336 let final_args = if extra_args_slice.is_empty() {
337 args_with_visitor
338 } else if args_with_visitor.is_empty() {
339 extra_args_slice.join(", ")
340 } else {
341 format!("{args_with_visitor}, {}", extra_args_slice.join(", "))
342 };
343
344 let return_type = if is_async { "async Task" } else { "void" };
345 let await_kw = if is_async { "await " } else { "" };
346
347 let _ = writeln!(out, " [Fact]");
348 let _ = writeln!(out, " public {return_type} Test_{method_name}()");
349 let _ = writeln!(out, " {{");
350 let _ = writeln!(out, " // {description}");
351
352 for line in &setup_lines {
353 let _ = writeln!(out, " {line}");
354 }
355
356 if expects_error {
357 if is_async {
358 let _ = writeln!(
359 out,
360 " await Assert.ThrowsAsync<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
361 );
362 } else {
363 let _ = writeln!(
364 out,
365 " Assert.Throws<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
366 );
367 }
368 let _ = writeln!(out, " }}");
369 return;
370 }
371
372 let result_is_vec = cs_overrides.is_some_and(|o| o.result_is_vec);
373
374 if returns_void {
375 let _ = writeln!(out, " {await_kw}{class_name}.{function_name}({final_args});");
376 } else {
377 let _ = writeln!(
378 out,
379 " var {result_var} = {await_kw}{class_name}.{function_name}({final_args});"
380 );
381 for assertion in &fixture.assertions {
382 render_assertion(
383 out,
384 assertion,
385 result_var,
386 class_name,
387 exception_class,
388 field_resolver,
389 effective_result_is_simple,
390 result_is_vec,
391 );
392 }
393 }
394
395 let _ = writeln!(out, " }}");
396}
397
398fn build_args_and_setup(
402 input: &serde_json::Value,
403 args: &[crate::config::ArgMapping],
404 class_name: &str,
405 options_type: Option<&str>,
406 _enum_fields: &HashMap<String, String>,
407 fixture_id: &str,
408) -> (Vec<String>, String) {
409 if args.is_empty() {
410 return (Vec::new(), String::new());
411 }
412
413 let mut setup_lines: Vec<String> = Vec::new();
414 let mut parts: Vec<String> = Vec::new();
415
416 for arg in args {
417 if arg.arg_type == "bytes" {
418 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
421 let val = input.get(field);
422 match val {
423 None | Some(serde_json::Value::Null) if arg.optional => {
424 parts.push("null".to_string());
425 }
426 None | Some(serde_json::Value::Null) => {
427 parts.push("System.Array.Empty<byte>()".to_string());
428 }
429 Some(v) => {
430 let cs_str = json_to_csharp(v);
431 parts.push(format!("System.Text.Encoding.UTF8.GetBytes({cs_str})"));
432 }
433 }
434 continue;
435 }
436
437 if arg.arg_type == "mock_url" {
438 setup_lines.push(format!(
439 "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
440 arg.name,
441 ));
442 parts.push(arg.name.clone());
443 continue;
444 }
445
446 if arg.arg_type == "handle" {
447 let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
449 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
450 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
451 if config_value.is_null()
452 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
453 {
454 setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
455 } else {
456 let sorted = sort_discriminator_first(config_value.clone());
460 let json_str = serde_json::to_string(&sorted).unwrap_or_default();
461 let name = &arg.name;
462 setup_lines.push(format!(
463 "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
464 escape_csharp(&json_str),
465 ));
466 setup_lines.push(format!(
467 "var {} = {class_name}.{constructor_name}({name}Config);",
468 arg.name,
469 name = name,
470 ));
471 }
472 parts.push(arg.name.clone());
473 continue;
474 }
475
476 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
477 let val = input.get(field);
478 match val {
479 None | Some(serde_json::Value::Null) if arg.optional => {
480 parts.push("null".to_string());
483 continue;
484 }
485 None | Some(serde_json::Value::Null) => {
486 let default_val = match arg.arg_type.as_str() {
488 "string" => "\"\"".to_string(),
489 "int" | "integer" => "0".to_string(),
490 "float" | "number" => "0.0d".to_string(),
491 "bool" | "boolean" => "false".to_string(),
492 _ => "null".to_string(),
493 };
494 parts.push(default_val);
495 }
496 Some(v) => {
497 if arg.arg_type == "json_object" {
498 if let Some(arr) = v.as_array() {
500 parts.push(json_array_to_csharp_list(arr, arg.element_type.as_deref()));
501 continue;
502 }
503 if let Some(opts_type) = options_type {
506 if v.is_object() {
507 let json_str = serde_json::to_string(v).unwrap_or_default();
508 parts.push(format!(
509 "JsonSerializer.Deserialize<{opts_type}>(\"{}\", ConfigOptions)!",
510 escape_csharp(&json_str),
511 ));
512 continue;
513 }
514 }
515 }
516 parts.push(json_to_csharp(v));
517 }
518 }
519 }
520
521 (setup_lines, parts.join(", "))
522}
523
524fn json_array_to_csharp_list(arr: &[serde_json::Value], element_type: Option<&str>) -> String {
531 match element_type {
532 Some("f32") => {
533 let items: Vec<String> = arr.iter().map(|v| format!("(float){}", json_to_csharp(v))).collect();
534 format!("new List<float>() {{ {} }}", items.join(", "))
535 }
536 Some("(String, String)") => {
537 let items: Vec<String> = arr
538 .iter()
539 .map(|v| {
540 let strs: Vec<String> = v
541 .as_array()
542 .map_or_else(Vec::new, |a| a.iter().map(json_to_csharp).collect());
543 format!("new List<string>() {{ {} }}", strs.join(", "))
544 })
545 .collect();
546 format!("new List<List<string>>() {{ {} }}", items.join(", "))
547 }
548 _ => {
549 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
550 format!("new List<string>() {{ {} }}", items.join(", "))
551 }
552 }
553}
554
555#[allow(clippy::too_many_arguments)]
556fn render_assertion(
557 out: &mut String,
558 assertion: &Assertion,
559 result_var: &str,
560 class_name: &str,
561 exception_class: &str,
562 field_resolver: &FieldResolver,
563 result_is_simple: bool,
564 result_is_vec: bool,
565) {
566 if let Some(f) = &assertion.field {
568 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
569 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
570 return;
571 }
572 }
573
574 let effective_result_var: String = if result_is_vec {
576 format!("{result_var}[0]")
577 } else {
578 result_var.to_string()
579 };
580
581 let field_expr = if result_is_simple {
582 effective_result_var.clone()
583 } else {
584 match &assertion.field {
585 Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", &effective_result_var),
586 _ => effective_result_var.clone(),
587 }
588 };
589
590 match assertion.assertion_type.as_str() {
591 "equals" => {
592 if let Some(expected) = &assertion.value {
593 let cs_val = json_to_csharp(expected);
594 if expected.is_string() {
595 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr}.Trim());");
597 } else if expected.as_bool() == Some(true) {
598 let _ = writeln!(out, " Assert.True({field_expr});");
600 } else if expected.as_bool() == Some(false) {
601 let _ = writeln!(out, " Assert.False({field_expr});");
603 } else if expected.is_number() && !expected.as_f64().is_some_and(|f| f.fract() != 0.0) {
604 let _ = writeln!(out, " Assert.True({field_expr} == {cs_val});");
607 } else {
608 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr});");
609 }
610 }
611 }
612 "contains" => {
613 if let Some(expected) = &assertion.value {
614 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
619 let cs_val = lower_expected
620 .as_deref()
621 .map(|s| format!("\"{}\"", escape_csharp(s)))
622 .unwrap_or_else(|| json_to_csharp(expected));
623 let _ = writeln!(
624 out,
625 " Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
626 );
627 }
628 }
629 "contains_all" => {
630 if let Some(values) = &assertion.values {
631 for val in values {
632 let lower_val = val.as_str().map(|s| s.to_lowercase());
633 let cs_val = lower_val
634 .as_deref()
635 .map(|s| format!("\"{}\"", escape_csharp(s)))
636 .unwrap_or_else(|| json_to_csharp(val));
637 let _ = writeln!(
638 out,
639 " Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
640 );
641 }
642 }
643 }
644 "not_contains" => {
645 if let Some(expected) = &assertion.value {
646 let cs_val = json_to_csharp(expected);
647 let _ = writeln!(out, " Assert.DoesNotContain({cs_val}, {field_expr}.ToString());");
648 }
649 }
650 "not_empty" => {
651 let _ = writeln!(
652 out,
653 " Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
654 );
655 }
656 "is_empty" => {
657 let _ = writeln!(
658 out,
659 " Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
660 );
661 }
662 "contains_any" => {
663 if let Some(values) = &assertion.values {
664 let checks: Vec<String> = values
665 .iter()
666 .map(|v| {
667 let cs_val = json_to_csharp(v);
668 format!("{field_expr}.ToString().Contains({cs_val})")
669 })
670 .collect();
671 let joined = checks.join(" || ");
672 let _ = writeln!(
673 out,
674 " Assert.True({joined}, \"expected to contain at least one of the specified values\");"
675 );
676 }
677 }
678 "greater_than" => {
679 if let Some(val) = &assertion.value {
680 let cs_val = json_to_csharp(val);
681 let _ = writeln!(
682 out,
683 " Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
684 );
685 }
686 }
687 "less_than" => {
688 if let Some(val) = &assertion.value {
689 let cs_val = json_to_csharp(val);
690 let _ = writeln!(
691 out,
692 " Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
693 );
694 }
695 }
696 "greater_than_or_equal" => {
697 if let Some(val) = &assertion.value {
698 let cs_val = json_to_csharp(val);
699 let _ = writeln!(
700 out,
701 " Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
702 );
703 }
704 }
705 "less_than_or_equal" => {
706 if let Some(val) = &assertion.value {
707 let cs_val = json_to_csharp(val);
708 let _ = writeln!(
709 out,
710 " Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
711 );
712 }
713 }
714 "starts_with" => {
715 if let Some(expected) = &assertion.value {
716 let cs_val = json_to_csharp(expected);
717 let _ = writeln!(out, " Assert.StartsWith({cs_val}, {field_expr});");
718 }
719 }
720 "ends_with" => {
721 if let Some(expected) = &assertion.value {
722 let cs_val = json_to_csharp(expected);
723 let _ = writeln!(out, " Assert.EndsWith({cs_val}, {field_expr});");
724 }
725 }
726 "min_length" => {
727 if let Some(val) = &assertion.value {
728 if let Some(n) = val.as_u64() {
729 let _ = writeln!(
730 out,
731 " Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
732 );
733 }
734 }
735 }
736 "max_length" => {
737 if let Some(val) = &assertion.value {
738 if let Some(n) = val.as_u64() {
739 let _ = writeln!(
740 out,
741 " Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
742 );
743 }
744 }
745 }
746 "count_min" => {
747 if let Some(val) = &assertion.value {
748 if let Some(n) = val.as_u64() {
749 let _ = writeln!(
750 out,
751 " Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
752 );
753 }
754 }
755 }
756 "count_equals" => {
757 if let Some(val) = &assertion.value {
758 if let Some(n) = val.as_u64() {
759 let _ = writeln!(out, " Assert.Equal({n}, {field_expr}.Count);");
760 }
761 }
762 }
763 "is_true" => {
764 let _ = writeln!(out, " Assert.True({field_expr});");
765 }
766 "is_false" => {
767 let _ = writeln!(out, " Assert.False({field_expr});");
768 }
769 "not_error" => {
770 }
772 "error" => {
773 }
775 "method_result" => {
776 if let Some(method_name) = &assertion.method {
777 let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
778 let check = assertion.check.as_deref().unwrap_or("is_true");
779 match check {
780 "equals" => {
781 if let Some(val) = &assertion.value {
782 if val.as_bool() == Some(true) {
783 let _ = writeln!(out, " Assert.True({call_expr});");
784 } else if val.as_bool() == Some(false) {
785 let _ = writeln!(out, " Assert.False({call_expr});");
786 } else {
787 let cs_val = json_to_csharp(val);
788 let _ = writeln!(out, " Assert.Equal({cs_val}, {call_expr});");
789 }
790 }
791 }
792 "is_true" => {
793 let _ = writeln!(out, " Assert.True({call_expr});");
794 }
795 "is_false" => {
796 let _ = writeln!(out, " Assert.False({call_expr});");
797 }
798 "greater_than_or_equal" => {
799 if let Some(val) = &assertion.value {
800 let n = val.as_u64().unwrap_or(0);
801 let _ = writeln!(out, " Assert.True({call_expr} >= {n}, \"expected >= {n}\");");
802 }
803 }
804 "count_min" => {
805 if let Some(val) = &assertion.value {
806 let n = val.as_u64().unwrap_or(0);
807 let _ = writeln!(
808 out,
809 " Assert.True({call_expr}.Count >= {n}, \"expected at least {n} elements\");"
810 );
811 }
812 }
813 "is_error" => {
814 let _ = writeln!(
815 out,
816 " Assert.Throws<{exception_class}>(() => {{ {call_expr}; }});"
817 );
818 }
819 "contains" => {
820 if let Some(val) = &assertion.value {
821 let cs_val = json_to_csharp(val);
822 let _ = writeln!(out, " Assert.Contains({cs_val}, {call_expr});");
823 }
824 }
825 other_check => {
826 panic!("C# e2e generator: unsupported method_result check type: {other_check}");
827 }
828 }
829 } else {
830 panic!("C# e2e generator: method_result assertion missing 'method' field");
831 }
832 }
833 "matches_regex" => {
834 if let Some(expected) = &assertion.value {
835 let cs_val = json_to_csharp(expected);
836 let _ = writeln!(out, " Assert.Matches({cs_val}, {field_expr});");
837 }
838 }
839 other => {
840 panic!("C# e2e generator: unsupported assertion type: {other}");
841 }
842 }
843}
844
845fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
852 match value {
853 serde_json::Value::Object(map) => {
854 let mut sorted = serde_json::Map::with_capacity(map.len());
855 if let Some(type_val) = map.get("type") {
857 sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
858 }
859 for (k, v) in map {
860 if k != "type" {
861 sorted.insert(k, sort_discriminator_first(v));
862 }
863 }
864 serde_json::Value::Object(sorted)
865 }
866 serde_json::Value::Array(arr) => {
867 serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
868 }
869 other => other,
870 }
871}
872
873fn json_to_csharp(value: &serde_json::Value) -> String {
875 match value {
876 serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
877 serde_json::Value::Bool(true) => "true".to_string(),
878 serde_json::Value::Bool(false) => "false".to_string(),
879 serde_json::Value::Number(n) => {
880 if n.is_f64() {
881 format!("{}d", n)
882 } else {
883 n.to_string()
884 }
885 }
886 serde_json::Value::Null => "null".to_string(),
887 serde_json::Value::Array(arr) => {
888 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
889 format!("new[] {{ {} }}", items.join(", "))
890 }
891 serde_json::Value::Object(_) => {
892 let json_str = serde_json::to_string(value).unwrap_or_default();
893 format!("\"{}\"", escape_csharp(&json_str))
894 }
895 }
896}
897
898fn build_csharp_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
904 setup_lines.push("var _testVisitor = new TestVisitor();".to_string());
905 setup_lines.push("class TestVisitor : IVisitor".to_string());
906 setup_lines.push("{".to_string());
907 for (method_name, action) in &visitor_spec.callbacks {
908 emit_csharp_visitor_method(setup_lines, method_name, action);
909 }
910 setup_lines.push("}".to_string());
911 "_testVisitor".to_string()
912}
913
914fn emit_csharp_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
916 let camel_method = method_to_camel(method_name);
917 let params = match method_name {
918 "visit_link" => "VisitContext ctx, string href, string text, string title",
919 "visit_image" => "VisitContext ctx, string src, string alt, string title",
920 "visit_heading" => "VisitContext ctx, int level, string text, string id",
921 "visit_code_block" => "VisitContext ctx, string lang, string code",
922 "visit_code_inline"
923 | "visit_strong"
924 | "visit_emphasis"
925 | "visit_strikethrough"
926 | "visit_underline"
927 | "visit_subscript"
928 | "visit_superscript"
929 | "visit_mark"
930 | "visit_button"
931 | "visit_summary"
932 | "visit_figcaption"
933 | "visit_definition_term"
934 | "visit_definition_description" => "VisitContext ctx, string text",
935 "visit_text" => "VisitContext ctx, string text",
936 "visit_list_item" => "VisitContext ctx, bool ordered, string marker, string text",
937 "visit_blockquote" => "VisitContext ctx, string content, int depth",
938 "visit_table_row" => "VisitContext ctx, IReadOnlyList<string> cells, bool isHeader",
939 "visit_custom_element" => "VisitContext ctx, string tagName, string html",
940 "visit_form" => "VisitContext ctx, string actionUrl, string method",
941 "visit_input" => "VisitContext ctx, string inputType, string name, string value",
942 "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, string src",
943 "visit_details" => "VisitContext ctx, bool isOpen",
944 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
945 "VisitContext ctx, string output"
946 }
947 "visit_list_start" => "VisitContext ctx, bool ordered",
948 "visit_list_end" => "VisitContext ctx, bool ordered, string output",
949 _ => "VisitContext ctx",
950 };
951
952 setup_lines.push(format!(" public VisitResult {camel_method}({params})"));
953 setup_lines.push(" {".to_string());
954 match action {
955 CallbackAction::Skip => {
956 setup_lines.push(" return VisitResult.Skip();".to_string());
957 }
958 CallbackAction::Continue => {
959 setup_lines.push(" return VisitResult.Continue();".to_string());
960 }
961 CallbackAction::PreserveHtml => {
962 setup_lines.push(" return VisitResult.PreserveHtml();".to_string());
963 }
964 CallbackAction::Custom { output } => {
965 let escaped = escape_csharp(output);
966 setup_lines.push(format!(" return VisitResult.Custom(\"{escaped}\");"));
967 }
968 CallbackAction::CustomTemplate { template } => {
969 let escaped = escape_csharp(template);
970 setup_lines.push(format!(" return VisitResult.Custom($\"{escaped}\");"));
971 }
972 }
973 setup_lines.push(" }".to_string());
974}
975
976fn method_to_camel(snake: &str) -> String {
978 use heck::ToUpperCamelCase;
979 snake.to_upper_camel_case()
980}
981
982fn build_csharp_method_call(
987 result_var: &str,
988 method_name: &str,
989 args: Option<&serde_json::Value>,
990 class_name: &str,
991) -> String {
992 match method_name {
993 "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
994 "root_node_type" => format!("{result_var}.RootNode.Kind"),
995 "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
996 "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
997 "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
998 "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
999 "contains_node_type" => {
1000 let node_type = args
1001 .and_then(|a| a.get("node_type"))
1002 .and_then(|v| v.as_str())
1003 .unwrap_or("");
1004 format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
1005 }
1006 "find_nodes_by_type" => {
1007 let node_type = args
1008 .and_then(|a| a.get("node_type"))
1009 .and_then(|v| v.as_str())
1010 .unwrap_or("");
1011 format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
1012 }
1013 "run_query" => {
1014 let query_source = args
1015 .and_then(|a| a.get("query_source"))
1016 .and_then(|v| v.as_str())
1017 .unwrap_or("");
1018 let language = args
1019 .and_then(|a| a.get("language"))
1020 .and_then(|v| v.as_str())
1021 .unwrap_or("");
1022 format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
1023 }
1024 _ => {
1025 use heck::ToUpperCamelCase;
1026 let pascal = method_name.to_upper_camel_case();
1027 format!("{result_var}.{pascal}()")
1028 }
1029 }
1030}