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