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