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 anyhow::Result;
14use heck::ToUpperCamelCase;
15use std::collections::HashMap;
16use std::fmt::Write as FmtWrite;
17use std::path::PathBuf;
18
19use super::E2eCodegen;
20
21pub struct CSharpCodegen;
23
24impl E2eCodegen for CSharpCodegen {
25 fn generate(
26 &self,
27 groups: &[FixtureGroup],
28 e2e_config: &E2eConfig,
29 alef_config: &AlefConfig,
30 ) -> Result<Vec<GeneratedFile>> {
31 let lang = self.language_name();
32 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
33
34 let mut files = Vec::new();
35
36 let call = &e2e_config.call;
38 let overrides = call.overrides.get(lang);
39 let function_name = overrides
40 .and_then(|o| o.function.as_ref())
41 .cloned()
42 .unwrap_or_else(|| call.function.to_upper_camel_case());
43 let class_name = overrides
44 .and_then(|o| o.class.as_ref())
45 .cloned()
46 .unwrap_or_else(|| format!("{}Lib", alef_config.crate_config.name.to_upper_camel_case()));
47 let exception_class = format!("{}Exception", alef_config.crate_config.name.to_upper_camel_case());
49 let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
50 if call.module.is_empty() {
51 "Kreuzberg".to_string()
52 } else {
53 call.module.to_upper_camel_case()
54 }
55 });
56 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
57 let result_var = &call.result_var;
58 let is_async = call.r#async;
59
60 let cs_pkg = e2e_config.resolve_package("csharp");
62 let pkg_name = cs_pkg
63 .as_ref()
64 .and_then(|p| p.name.as_ref())
65 .cloned()
66 .unwrap_or_else(|| alef_config.crate_config.name.to_upper_camel_case());
67 let pkg_path = cs_pkg
70 .as_ref()
71 .and_then(|p| p.path.as_ref())
72 .cloned()
73 .unwrap_or_else(|| format!("../../packages/csharp/{pkg_name}.csproj"));
74 let pkg_version = cs_pkg
75 .as_ref()
76 .and_then(|p| p.version.as_ref())
77 .cloned()
78 .unwrap_or_else(|| "0.1.0".to_string());
79
80 let csproj_name = format!("{pkg_name}.E2eTests.csproj");
83 files.push(GeneratedFile {
84 path: output_base.join(&csproj_name),
85 content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
86 generated_header: false,
87 });
88
89 let tests_base = output_base.join("tests");
91 let field_resolver = FieldResolver::new(
92 &e2e_config.fields,
93 &e2e_config.fields_optional,
94 &e2e_config.result_fields,
95 &e2e_config.fields_array,
96 );
97
98 static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
100 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
101
102 for group in groups {
103 let active: Vec<&Fixture> = group
104 .fixtures
105 .iter()
106 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
107 .collect();
108
109 if active.is_empty() {
110 continue;
111 }
112
113 let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
114 let filename = format!("{test_class}.cs");
115 let content = render_test_file(
116 &group.category,
117 &active,
118 &namespace,
119 &class_name,
120 &function_name,
121 &exception_class,
122 result_var,
123 &test_class,
124 &e2e_config.call.args,
125 &field_resolver,
126 result_is_simple,
127 is_async,
128 e2e_config,
129 enum_fields,
130 );
131 files.push(GeneratedFile {
132 path: tests_base.join(filename),
133 content,
134 generated_header: true,
135 });
136 }
137
138 Ok(files)
139 }
140
141 fn language_name(&self) -> &'static str {
142 "csharp"
143 }
144}
145
146fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
151 let pkg_ref = match dep_mode {
152 crate::config::DependencyMode::Registry => {
153 format!(" <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
154 }
155 crate::config::DependencyMode::Local => {
156 format!(" <ProjectReference Include=\"{pkg_path}\" />")
157 }
158 };
159 format!(
160 r#"<Project Sdk="Microsoft.NET.Sdk">
161 <PropertyGroup>
162 <TargetFramework>net10.0</TargetFramework>
163 <Nullable>enable</Nullable>
164 <ImplicitUsings>enable</ImplicitUsings>
165 <IsPackable>false</IsPackable>
166 <IsTestProject>true</IsTestProject>
167 </PropertyGroup>
168
169 <ItemGroup>
170 <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
171 <PackageReference Include="xunit" Version="2.9.3" />
172 <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
173 </ItemGroup>
174
175 <ItemGroup>
176{pkg_ref}
177 </ItemGroup>
178</Project>
179"#
180 )
181}
182
183#[allow(clippy::too_many_arguments)]
184fn render_test_file(
185 category: &str,
186 fixtures: &[&Fixture],
187 namespace: &str,
188 class_name: &str,
189 function_name: &str,
190 exception_class: &str,
191 result_var: &str,
192 test_class: &str,
193 args: &[crate::config::ArgMapping],
194 field_resolver: &FieldResolver,
195 result_is_simple: bool,
196 is_async: bool,
197 e2e_config: &E2eConfig,
198 enum_fields: &HashMap<String, String>,
199) -> String {
200 let mut out = String::new();
201 out.push_str(&hash::header(CommentStyle::DoubleSlash));
202 let _ = writeln!(out, "using System.Text.Json;");
204 let _ = writeln!(out, "using System.Text.Json.Serialization;");
205 let _ = writeln!(out, "using System.Threading.Tasks;");
206 let _ = writeln!(out, "using Xunit;");
207 let _ = writeln!(out, "using {namespace};");
208 let _ = writeln!(out);
209 let _ = writeln!(out, "namespace Kreuzberg.E2e;");
210 let _ = writeln!(out);
211 let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
212 let _ = writeln!(out, "public class {test_class}");
213 let _ = writeln!(out, "{{");
214 let _ = writeln!(
217 out,
218 " private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
219 );
220 let _ = writeln!(out);
221
222 for (i, fixture) in fixtures.iter().enumerate() {
223 render_test_method(
224 &mut out,
225 fixture,
226 class_name,
227 function_name,
228 exception_class,
229 result_var,
230 args,
231 field_resolver,
232 result_is_simple,
233 is_async,
234 e2e_config,
235 enum_fields,
236 );
237 if i + 1 < fixtures.len() {
238 let _ = writeln!(out);
239 }
240 }
241
242 let _ = writeln!(out, "}}");
243 out
244}
245
246#[allow(clippy::too_many_arguments)]
247fn render_test_method(
248 out: &mut String,
249 fixture: &Fixture,
250 class_name: &str,
251 _function_name: &str,
252 exception_class: &str,
253 _result_var: &str,
254 _args: &[crate::config::ArgMapping],
255 field_resolver: &FieldResolver,
256 result_is_simple: bool,
257 _is_async: bool,
258 e2e_config: &E2eConfig,
259 enum_fields: &HashMap<String, String>,
260) {
261 let method_name = fixture.id.to_upper_camel_case();
262 let description = &fixture.description;
263 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
264
265 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
268 let lang = "csharp";
269 let cs_overrides = call_config.overrides.get(lang);
270 let effective_function_name = cs_overrides
271 .and_then(|o| o.function.as_ref())
272 .cloned()
273 .unwrap_or_else(|| call_config.function.to_upper_camel_case());
274 let effective_result_var = &call_config.result_var;
275 let effective_is_async = call_config.r#async;
276 let function_name = effective_function_name.as_str();
277 let result_var = effective_result_var.as_str();
278 let is_async = effective_is_async;
279 let args = call_config.args.as_slice();
280
281 let (mut setup_lines, args_str) =
282 build_args_and_setup(&fixture.input, args, class_name, e2e_config, enum_fields, &fixture.id);
283
284 let mut visitor_arg = String::new();
286 if let Some(visitor_spec) = &fixture.visitor {
287 visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_spec);
288 }
289
290 let final_args = if visitor_arg.is_empty() {
291 args_str
292 } else {
293 format!("{args_str}, {visitor_arg}")
294 };
295
296 let return_type = if is_async { "async Task" } else { "void" };
297 let await_kw = if is_async { "await " } else { "" };
298
299 let _ = writeln!(out, " [Fact]");
300 let _ = writeln!(out, " public {return_type} Test_{method_name}()");
301 let _ = writeln!(out, " {{");
302 let _ = writeln!(out, " // {description}");
303
304 for line in &setup_lines {
305 let _ = writeln!(out, " {line}");
306 }
307
308 if expects_error {
309 if is_async {
310 let _ = writeln!(
311 out,
312 " await Assert.ThrowsAsync<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
313 );
314 } else {
315 let _ = writeln!(
316 out,
317 " Assert.Throws<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
318 );
319 }
320 let _ = writeln!(out, " }}");
321 return;
322 }
323
324 let _ = writeln!(
325 out,
326 " var {result_var} = {await_kw}{class_name}.{function_name}({final_args});"
327 );
328
329 for assertion in &fixture.assertions {
330 render_assertion(
331 out,
332 assertion,
333 result_var,
334 class_name,
335 exception_class,
336 field_resolver,
337 result_is_simple,
338 );
339 }
340
341 let _ = writeln!(out, " }}");
342}
343
344fn build_args_and_setup(
348 input: &serde_json::Value,
349 args: &[crate::config::ArgMapping],
350 class_name: &str,
351 e2e_config: &E2eConfig,
352 enum_fields: &HashMap<String, String>,
353 fixture_id: &str,
354) -> (Vec<String>, String) {
355 if args.is_empty() {
356 return (Vec::new(), json_to_csharp(input));
357 }
358
359 let overrides = e2e_config.call.overrides.get("csharp");
360 let options_type = overrides.and_then(|o| o.options_type.as_deref());
361
362 let mut setup_lines: Vec<String> = Vec::new();
363 let mut parts: Vec<String> = Vec::new();
364
365 for arg in args {
366 if arg.arg_type == "mock_url" {
367 setup_lines.push(format!(
368 "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
369 arg.name,
370 ));
371 parts.push(arg.name.clone());
372 continue;
373 }
374
375 if arg.arg_type == "handle" {
376 let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
378 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
379 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
380 if config_value.is_null()
381 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
382 {
383 setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
384 } else {
385 let sorted = sort_discriminator_first(config_value.clone());
389 let json_str = serde_json::to_string(&sorted).unwrap_or_default();
390 let name = &arg.name;
391 setup_lines.push(format!(
392 "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
393 escape_csharp(&json_str),
394 ));
395 setup_lines.push(format!(
396 "var {} = {class_name}.{constructor_name}({name}Config);",
397 arg.name,
398 name = name,
399 ));
400 }
401 parts.push(arg.name.clone());
402 continue;
403 }
404
405 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());
412 continue;
413 }
414 None | Some(serde_json::Value::Null) => {
415 let default_val = match arg.arg_type.as_str() {
417 "string" => "\"\"".to_string(),
418 "int" | "integer" => "0".to_string(),
419 "float" | "number" => "0.0d".to_string(),
420 "bool" | "boolean" => "false".to_string(),
421 _ => "null".to_string(),
422 };
423 parts.push(default_val);
424 }
425 Some(v) => {
426 if let (Some(opts_type), "json_object") = (options_type, arg.arg_type.as_str()) {
428 if let Some(obj) = v.as_object() {
429 let props: Vec<String> = obj
430 .iter()
431 .map(|(k, vv)| {
432 let pascal_key = k.to_upper_camel_case();
433 let cs_val = if let Some(enum_type) = enum_fields.get(k) {
435 if let Some(s) = vv.as_str() {
437 let pascal_val = s.to_upper_camel_case();
438 format!("{enum_type}.{pascal_val}")
439 } else {
440 json_to_csharp(vv)
441 }
442 } else {
443 json_to_csharp(vv)
444 };
445 format!("{pascal_key} = {cs_val}")
446 })
447 .collect();
448 parts.push(format!("new {opts_type} {{ {} }}", props.join(", ")));
449 continue;
450 }
451 }
452 parts.push(json_to_csharp(v));
453 }
454 }
455 }
456
457 (setup_lines, parts.join(", "))
458}
459
460fn render_assertion(
461 out: &mut String,
462 assertion: &Assertion,
463 result_var: &str,
464 class_name: &str,
465 exception_class: &str,
466 field_resolver: &FieldResolver,
467 result_is_simple: bool,
468) {
469 if let Some(f) = &assertion.field {
471 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
472 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
473 return;
474 }
475 }
476
477 let field_expr = if result_is_simple {
478 result_var.to_string()
479 } else {
480 match &assertion.field {
481 Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", result_var),
482 _ => result_var.to_string(),
483 }
484 };
485
486 let field_is_optional = assertion
488 .field
489 .as_deref()
490 .map(|f| field_resolver.is_optional(field_resolver.resolve(f)))
491 .unwrap_or(false);
492
493 match assertion.assertion_type.as_str() {
494 "equals" => {
495 if let Some(expected) = &assertion.value {
496 let cs_val = json_to_csharp(expected);
497 if expected.is_string() {
499 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr}.Trim());");
500 } else if expected.is_number() && field_is_optional {
501 let _ = writeln!(out, " Assert.Equal((object?){cs_val}, (object?){field_expr});");
504 } else {
505 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr});");
506 }
507 }
508 }
509 "contains" => {
510 if let Some(expected) = &assertion.value {
511 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
516 let cs_val = lower_expected
517 .as_deref()
518 .map(|s| format!("\"{}\"", escape_csharp(s)))
519 .unwrap_or_else(|| json_to_csharp(expected));
520 let _ = writeln!(
521 out,
522 " Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
523 );
524 }
525 }
526 "contains_all" => {
527 if let Some(values) = &assertion.values {
528 for val in values {
529 let lower_val = val.as_str().map(|s| s.to_lowercase());
530 let cs_val = lower_val
531 .as_deref()
532 .map(|s| format!("\"{}\"", escape_csharp(s)))
533 .unwrap_or_else(|| json_to_csharp(val));
534 let _ = writeln!(
535 out,
536 " Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
537 );
538 }
539 }
540 }
541 "not_contains" => {
542 if let Some(expected) = &assertion.value {
543 let cs_val = json_to_csharp(expected);
544 let _ = writeln!(out, " Assert.DoesNotContain({cs_val}, {field_expr}.ToString());");
545 }
546 }
547 "not_empty" => {
548 let _ = writeln!(
549 out,
550 " Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
551 );
552 }
553 "is_empty" => {
554 let _ = writeln!(
555 out,
556 " Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
557 );
558 }
559 "contains_any" => {
560 if let Some(values) = &assertion.values {
561 let checks: Vec<String> = values
562 .iter()
563 .map(|v| {
564 let cs_val = json_to_csharp(v);
565 format!("{field_expr}.ToString().Contains({cs_val})")
566 })
567 .collect();
568 let joined = checks.join(" || ");
569 let _ = writeln!(
570 out,
571 " Assert.True({joined}, \"expected to contain at least one of the specified values\");"
572 );
573 }
574 }
575 "greater_than" => {
576 if let Some(val) = &assertion.value {
577 let cs_val = json_to_csharp(val);
578 let _ = writeln!(
579 out,
580 " Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
581 );
582 }
583 }
584 "less_than" => {
585 if let Some(val) = &assertion.value {
586 let cs_val = json_to_csharp(val);
587 let _ = writeln!(
588 out,
589 " Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
590 );
591 }
592 }
593 "greater_than_or_equal" => {
594 if let Some(val) = &assertion.value {
595 let cs_val = json_to_csharp(val);
596 let _ = writeln!(
597 out,
598 " Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
599 );
600 }
601 }
602 "less_than_or_equal" => {
603 if let Some(val) = &assertion.value {
604 let cs_val = json_to_csharp(val);
605 let _ = writeln!(
606 out,
607 " Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
608 );
609 }
610 }
611 "starts_with" => {
612 if let Some(expected) = &assertion.value {
613 let cs_val = json_to_csharp(expected);
614 let _ = writeln!(out, " Assert.StartsWith({cs_val}, {field_expr});");
615 }
616 }
617 "ends_with" => {
618 if let Some(expected) = &assertion.value {
619 let cs_val = json_to_csharp(expected);
620 let _ = writeln!(out, " Assert.EndsWith({cs_val}, {field_expr});");
621 }
622 }
623 "min_length" => {
624 if let Some(val) = &assertion.value {
625 if let Some(n) = val.as_u64() {
626 let _ = writeln!(
627 out,
628 " Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
629 );
630 }
631 }
632 }
633 "max_length" => {
634 if let Some(val) = &assertion.value {
635 if let Some(n) = val.as_u64() {
636 let _ = writeln!(
637 out,
638 " Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
639 );
640 }
641 }
642 }
643 "count_min" => {
644 if let Some(val) = &assertion.value {
645 if let Some(n) = val.as_u64() {
646 let _ = writeln!(
647 out,
648 " Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
649 );
650 }
651 }
652 }
653 "count_equals" => {
654 if let Some(val) = &assertion.value {
655 if let Some(n) = val.as_u64() {
656 let _ = writeln!(out, " Assert.Equal({n}, {field_expr}.Count);");
657 }
658 }
659 }
660 "is_true" => {
661 let _ = writeln!(out, " Assert.True({field_expr});");
662 }
663 "is_false" => {
664 let _ = writeln!(out, " Assert.False({field_expr});");
665 }
666 "not_error" => {
667 }
669 "error" => {
670 }
672 "method_result" => {
673 if let Some(method_name) = &assertion.method {
674 let call_expr = build_csharp_method_call(result_var, method_name, assertion.args.as_ref(), class_name);
675 let check = assertion.check.as_deref().unwrap_or("is_true");
676 match check {
677 "equals" => {
678 if let Some(val) = &assertion.value {
679 if val.as_bool() == Some(true) {
680 let _ = writeln!(out, " Assert.True({call_expr});");
681 } else if val.as_bool() == Some(false) {
682 let _ = writeln!(out, " Assert.False({call_expr});");
683 } else {
684 let cs_val = json_to_csharp(val);
685 let _ = writeln!(out, " Assert.Equal({cs_val}, {call_expr});");
686 }
687 }
688 }
689 "is_true" => {
690 let _ = writeln!(out, " Assert.True({call_expr});");
691 }
692 "is_false" => {
693 let _ = writeln!(out, " Assert.False({call_expr});");
694 }
695 "greater_than_or_equal" => {
696 if let Some(val) = &assertion.value {
697 let n = val.as_u64().unwrap_or(0);
698 let _ = writeln!(out, " Assert.True({call_expr} >= {n}, \"expected >= {n}\");");
699 }
700 }
701 "count_min" => {
702 if let Some(val) = &assertion.value {
703 let n = val.as_u64().unwrap_or(0);
704 let _ = writeln!(
705 out,
706 " Assert.True({call_expr}.Count >= {n}, \"expected at least {n} elements\");"
707 );
708 }
709 }
710 "is_error" => {
711 let _ = writeln!(
712 out,
713 " Assert.Throws<{exception_class}>(() => {{ {call_expr}; }});"
714 );
715 }
716 "contains" => {
717 if let Some(val) = &assertion.value {
718 let cs_val = json_to_csharp(val);
719 let _ = writeln!(out, " Assert.Contains({cs_val}, {call_expr});");
720 }
721 }
722 other_check => {
723 panic!("C# e2e generator: unsupported method_result check type: {other_check}");
724 }
725 }
726 } else {
727 panic!("C# e2e generator: method_result assertion missing 'method' field");
728 }
729 }
730 "matches_regex" => {
731 if let Some(expected) = &assertion.value {
732 let cs_val = json_to_csharp(expected);
733 let _ = writeln!(out, " Assert.Matches({cs_val}, {field_expr});");
734 }
735 }
736 other => {
737 panic!("C# e2e generator: unsupported assertion type: {other}");
738 }
739 }
740}
741
742fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
749 match value {
750 serde_json::Value::Object(map) => {
751 let mut sorted = serde_json::Map::with_capacity(map.len());
752 if let Some(type_val) = map.get("type") {
754 sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
755 }
756 for (k, v) in map {
757 if k != "type" {
758 sorted.insert(k, sort_discriminator_first(v));
759 }
760 }
761 serde_json::Value::Object(sorted)
762 }
763 serde_json::Value::Array(arr) => {
764 serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
765 }
766 other => other,
767 }
768}
769
770fn json_to_csharp(value: &serde_json::Value) -> String {
772 match value {
773 serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
774 serde_json::Value::Bool(true) => "true".to_string(),
775 serde_json::Value::Bool(false) => "false".to_string(),
776 serde_json::Value::Number(n) => {
777 if n.is_f64() {
778 format!("{}d", n)
779 } else {
780 n.to_string()
781 }
782 }
783 serde_json::Value::Null => "null".to_string(),
784 serde_json::Value::Array(arr) => {
785 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
786 format!("new[] {{ {} }}", items.join(", "))
787 }
788 serde_json::Value::Object(_) => {
789 let json_str = serde_json::to_string(value).unwrap_or_default();
790 format!("\"{}\"", escape_csharp(&json_str))
791 }
792 }
793}
794
795fn build_csharp_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
801 setup_lines.push("var _testVisitor = new TestVisitor();".to_string());
802 setup_lines.push("class TestVisitor : IVisitor".to_string());
803 setup_lines.push("{".to_string());
804 for (method_name, action) in &visitor_spec.callbacks {
805 emit_csharp_visitor_method(setup_lines, method_name, action);
806 }
807 setup_lines.push("}".to_string());
808 "_testVisitor".to_string()
809}
810
811fn emit_csharp_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
813 let camel_method = method_to_camel(method_name);
814 let params = match method_name {
815 "visit_link" => "VisitContext ctx, string href, string text, string title",
816 "visit_image" => "VisitContext ctx, string src, string alt, string title",
817 "visit_heading" => "VisitContext ctx, int level, string text, string id",
818 "visit_code_block" => "VisitContext ctx, string lang, string code",
819 "visit_code_inline"
820 | "visit_strong"
821 | "visit_emphasis"
822 | "visit_strikethrough"
823 | "visit_underline"
824 | "visit_subscript"
825 | "visit_superscript"
826 | "visit_mark"
827 | "visit_button"
828 | "visit_summary"
829 | "visit_figcaption"
830 | "visit_definition_term"
831 | "visit_definition_description" => "VisitContext ctx, string text",
832 "visit_text" => "VisitContext ctx, string text",
833 "visit_list_item" => "VisitContext ctx, bool ordered, string marker, string text",
834 "visit_blockquote" => "VisitContext ctx, string content, int depth",
835 "visit_table_row" => "VisitContext ctx, IReadOnlyList<string> cells, bool isHeader",
836 "visit_custom_element" => "VisitContext ctx, string tagName, string html",
837 "visit_form" => "VisitContext ctx, string actionUrl, string method",
838 "visit_input" => "VisitContext ctx, string inputType, string name, string value",
839 "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, string src",
840 "visit_details" => "VisitContext ctx, bool isOpen",
841 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => {
842 "VisitContext ctx, string output"
843 }
844 "visit_list_start" => "VisitContext ctx, bool ordered",
845 "visit_list_end" => "VisitContext ctx, bool ordered, string output",
846 _ => "VisitContext ctx",
847 };
848
849 setup_lines.push(format!(" public VisitResult {camel_method}({params})"));
850 setup_lines.push(" {".to_string());
851 match action {
852 CallbackAction::Skip => {
853 setup_lines.push(" return VisitResult.Skip();".to_string());
854 }
855 CallbackAction::Continue => {
856 setup_lines.push(" return VisitResult.Continue();".to_string());
857 }
858 CallbackAction::PreserveHtml => {
859 setup_lines.push(" return VisitResult.PreserveHtml();".to_string());
860 }
861 CallbackAction::Custom { output } => {
862 let escaped = escape_csharp(output);
863 setup_lines.push(format!(" return VisitResult.Custom(\"{escaped}\");"));
864 }
865 CallbackAction::CustomTemplate { template } => {
866 let escaped = escape_csharp(template);
867 setup_lines.push(format!(" return VisitResult.Custom($\"{escaped}\");"));
868 }
869 }
870 setup_lines.push(" }".to_string());
871}
872
873fn method_to_camel(snake: &str) -> String {
875 use heck::ToUpperCamelCase;
876 snake.to_upper_camel_case()
877}
878
879fn build_csharp_method_call(
884 result_var: &str,
885 method_name: &str,
886 args: Option<&serde_json::Value>,
887 class_name: &str,
888) -> String {
889 match method_name {
890 "root_child_count" => format!("{result_var}.RootNode.ChildCount"),
891 "root_node_type" => format!("{result_var}.RootNode.Kind"),
892 "named_children_count" => format!("{result_var}.RootNode.NamedChildCount"),
893 "has_error_nodes" => format!("{class_name}.TreeHasErrorNodes({result_var})"),
894 "error_count" | "tree_error_count" => format!("{class_name}.TreeErrorCount({result_var})"),
895 "tree_to_sexp" => format!("{class_name}.TreeToSexp({result_var})"),
896 "contains_node_type" => {
897 let node_type = args
898 .and_then(|a| a.get("node_type"))
899 .and_then(|v| v.as_str())
900 .unwrap_or("");
901 format!("{class_name}.TreeContainsNodeType({result_var}, \"{node_type}\")")
902 }
903 "find_nodes_by_type" => {
904 let node_type = args
905 .and_then(|a| a.get("node_type"))
906 .and_then(|v| v.as_str())
907 .unwrap_or("");
908 format!("{class_name}.FindNodesByType({result_var}, \"{node_type}\")")
909 }
910 "run_query" => {
911 let query_source = args
912 .and_then(|a| a.get("query_source"))
913 .and_then(|v| v.as_str())
914 .unwrap_or("");
915 let language = args
916 .and_then(|a| a.get("language"))
917 .and_then(|v| v.as_str())
918 .unwrap_or("");
919 format!("{class_name}.RunQuery({result_var}, \"{language}\", \"{query_source}\", source)")
920 }
921 _ => {
922 use heck::ToUpperCamelCase;
923 let pascal = method_name.to_upper_camel_case();
924 format!("{result_var}.{pascal}()")
925 }
926 }
927}