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