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(|| {
73 let dir_name = &alef_config.crate_config.name;
74 format!("../../packages/csharp/{dir_name}/{pkg_name}.csproj")
75 });
76 let pkg_version = cs_pkg
77 .as_ref()
78 .and_then(|p| p.version.as_ref())
79 .cloned()
80 .unwrap_or_else(|| "0.1.0".to_string());
81
82 let csproj_name = format!("{pkg_name}.E2eTests.csproj");
85 files.push(GeneratedFile {
86 path: output_base.join(&csproj_name),
87 content: render_csproj(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
88 generated_header: false,
89 });
90
91 let tests_base = output_base.join("tests");
93 let field_resolver = FieldResolver::new(
94 &e2e_config.fields,
95 &e2e_config.fields_optional,
96 &e2e_config.result_fields,
97 &e2e_config.fields_array,
98 );
99
100 static EMPTY_ENUM_FIELDS: std::sync::LazyLock<HashMap<String, String>> = std::sync::LazyLock::new(HashMap::new);
102 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&EMPTY_ENUM_FIELDS);
103
104 for group in groups {
105 let active: Vec<&Fixture> = group
106 .fixtures
107 .iter()
108 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
109 .collect();
110
111 if active.is_empty() {
112 continue;
113 }
114
115 let test_class = format!("{}Tests", sanitize_filename(&group.category).to_upper_camel_case());
116 let filename = format!("{test_class}.cs");
117 let content = render_test_file(
118 &group.category,
119 &active,
120 &namespace,
121 &class_name,
122 &function_name,
123 &exception_class,
124 result_var,
125 &test_class,
126 &e2e_config.call.args,
127 &field_resolver,
128 result_is_simple,
129 is_async,
130 e2e_config,
131 enum_fields,
132 );
133 files.push(GeneratedFile {
134 path: tests_base.join(filename),
135 content,
136 generated_header: true,
137 });
138 }
139
140 Ok(files)
141 }
142
143 fn language_name(&self) -> &'static str {
144 "csharp"
145 }
146}
147
148fn render_csproj(pkg_name: &str, pkg_path: &str, pkg_version: &str, dep_mode: crate::config::DependencyMode) -> String {
153 let pkg_ref = match dep_mode {
154 crate::config::DependencyMode::Registry => {
155 format!(" <PackageReference Include=\"{pkg_name}\" Version=\"{pkg_version}\" />")
156 }
157 crate::config::DependencyMode::Local => {
158 format!(" <ProjectReference Include=\"{pkg_path}\" />")
159 }
160 };
161 format!(
162 r#"<Project Sdk="Microsoft.NET.Sdk">
163 <PropertyGroup>
164 <TargetFramework>net10.0</TargetFramework>
165 <Nullable>enable</Nullable>
166 <ImplicitUsings>enable</ImplicitUsings>
167 <IsPackable>false</IsPackable>
168 <IsTestProject>true</IsTestProject>
169 </PropertyGroup>
170
171 <ItemGroup>
172 <PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
173 <PackageReference Include="xunit" Version="2.9.3" />
174 <PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
175 </ItemGroup>
176
177 <ItemGroup>
178{pkg_ref}
179 </ItemGroup>
180</Project>
181"#
182 )
183}
184
185#[allow(clippy::too_many_arguments)]
186fn render_test_file(
187 category: &str,
188 fixtures: &[&Fixture],
189 namespace: &str,
190 class_name: &str,
191 function_name: &str,
192 exception_class: &str,
193 result_var: &str,
194 test_class: &str,
195 args: &[crate::config::ArgMapping],
196 field_resolver: &FieldResolver,
197 result_is_simple: bool,
198 is_async: bool,
199 e2e_config: &E2eConfig,
200 enum_fields: &HashMap<String, String>,
201) -> String {
202 let mut out = String::new();
203 let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
204 let _ = writeln!(out, "using System.Text.Json;");
206 let _ = writeln!(out, "using System.Text.Json.Serialization;");
207 let _ = writeln!(out, "using System.Threading.Tasks;");
208 let _ = writeln!(out, "using Xunit;");
209 let _ = writeln!(out, "using {namespace};");
210 let _ = writeln!(out);
211 let _ = writeln!(out, "namespace Kreuzberg.E2e;");
212 let _ = writeln!(out);
213 let _ = writeln!(out, "/// <summary>E2e tests for category: {category}.</summary>");
214 let _ = writeln!(out, "public class {test_class}");
215 let _ = writeln!(out, "{{");
216 let _ = writeln!(
219 out,
220 " private static readonly JsonSerializerOptions ConfigOptions = new() {{ Converters = {{ new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }}, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault }};"
221 );
222 let _ = writeln!(out);
223
224 for (i, fixture) in fixtures.iter().enumerate() {
225 render_test_method(
226 &mut out,
227 fixture,
228 class_name,
229 function_name,
230 exception_class,
231 result_var,
232 args,
233 field_resolver,
234 result_is_simple,
235 is_async,
236 e2e_config,
237 enum_fields,
238 );
239 if i + 1 < fixtures.len() {
240 let _ = writeln!(out);
241 }
242 }
243
244 let _ = writeln!(out, "}}");
245 out
246}
247
248#[allow(clippy::too_many_arguments)]
249fn render_test_method(
250 out: &mut String,
251 fixture: &Fixture,
252 class_name: &str,
253 function_name: &str,
254 exception_class: &str,
255 result_var: &str,
256 args: &[crate::config::ArgMapping],
257 field_resolver: &FieldResolver,
258 result_is_simple: bool,
259 is_async: bool,
260 e2e_config: &E2eConfig,
261 enum_fields: &HashMap<String, String>,
262) {
263 let method_name = fixture.id.to_upper_camel_case();
264 let description = &fixture.description;
265 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
266
267 let (mut setup_lines, args_str) =
268 build_args_and_setup(&fixture.input, args, class_name, e2e_config, enum_fields, &fixture.id);
269
270 let mut visitor_arg = String::new();
272 if let Some(visitor_spec) = &fixture.visitor {
273 visitor_arg = build_csharp_visitor(&mut setup_lines, visitor_spec);
274 }
275
276 let final_args = if visitor_arg.is_empty() {
277 args_str
278 } else {
279 format!("{args_str}, {visitor_arg}")
280 };
281
282 let return_type = if is_async { "async Task" } else { "void" };
283 let await_kw = if is_async { "await " } else { "" };
284
285 let _ = writeln!(out, " [Fact]");
286 let _ = writeln!(out, " public {return_type} Test_{method_name}()");
287 let _ = writeln!(out, " {{");
288 let _ = writeln!(out, " // {description}");
289
290 for line in &setup_lines {
291 let _ = writeln!(out, " {line}");
292 }
293
294 if expects_error {
295 if is_async {
296 let _ = writeln!(
297 out,
298 " await Assert.ThrowsAsync<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
299 );
300 } else {
301 let _ = writeln!(
302 out,
303 " Assert.Throws<{exception_class}>(() => {class_name}.{function_name}({final_args}));"
304 );
305 }
306 let _ = writeln!(out, " }}");
307 return;
308 }
309
310 let _ = writeln!(
311 out,
312 " var {result_var} = {await_kw}{class_name}.{function_name}({final_args});"
313 );
314
315 for assertion in &fixture.assertions {
316 render_assertion(out, assertion, result_var, field_resolver, result_is_simple);
317 }
318
319 let _ = writeln!(out, " }}");
320}
321
322fn build_args_and_setup(
326 input: &serde_json::Value,
327 args: &[crate::config::ArgMapping],
328 class_name: &str,
329 e2e_config: &E2eConfig,
330 enum_fields: &HashMap<String, String>,
331 fixture_id: &str,
332) -> (Vec<String>, String) {
333 if args.is_empty() {
334 return (Vec::new(), json_to_csharp(input));
335 }
336
337 let overrides = e2e_config.call.overrides.get("csharp");
338 let options_type = overrides.and_then(|o| o.options_type.as_deref());
339
340 let mut setup_lines: Vec<String> = Vec::new();
341 let mut parts: Vec<String> = Vec::new();
342
343 for arg in args {
344 if arg.arg_type == "mock_url" {
345 setup_lines.push(format!(
346 "var {} = Environment.GetEnvironmentVariable(\"MOCK_SERVER_URL\") + \"/fixtures/{fixture_id}\";",
347 arg.name,
348 ));
349 parts.push(arg.name.clone());
350 continue;
351 }
352
353 if arg.arg_type == "handle" {
354 let constructor_name = format!("Create{}", arg.name.to_upper_camel_case());
356 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
357 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
358 if config_value.is_null()
359 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
360 {
361 setup_lines.push(format!("var {} = {class_name}.{constructor_name}(null);", arg.name,));
362 } else {
363 let sorted = sort_discriminator_first(config_value.clone());
367 let json_str = serde_json::to_string(&sorted).unwrap_or_default();
368 let name = &arg.name;
369 setup_lines.push(format!(
370 "var {name}Config = JsonSerializer.Deserialize<CrawlConfig>(\"{}\", ConfigOptions)!;",
371 escape_csharp(&json_str),
372 ));
373 setup_lines.push(format!(
374 "var {} = {class_name}.{constructor_name}({name}Config);",
375 arg.name,
376 name = name,
377 ));
378 }
379 parts.push(arg.name.clone());
380 continue;
381 }
382
383 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
384 let val = input.get(field);
385 match val {
386 None | Some(serde_json::Value::Null) if arg.optional => {
387 parts.push("null".to_string());
390 continue;
391 }
392 None | Some(serde_json::Value::Null) => {
393 let default_val = match arg.arg_type.as_str() {
395 "string" => "\"\"".to_string(),
396 "int" | "integer" => "0".to_string(),
397 "float" | "number" => "0.0d".to_string(),
398 "bool" | "boolean" => "false".to_string(),
399 _ => "null".to_string(),
400 };
401 parts.push(default_val);
402 }
403 Some(v) => {
404 if let (Some(opts_type), "json_object") = (options_type, arg.arg_type.as_str()) {
406 if let Some(obj) = v.as_object() {
407 let props: Vec<String> = obj
408 .iter()
409 .map(|(k, vv)| {
410 let pascal_key = k.to_upper_camel_case();
411 let cs_val = if let Some(enum_type) = enum_fields.get(k) {
413 if let Some(s) = vv.as_str() {
415 let pascal_val = s.to_upper_camel_case();
416 format!("{enum_type}.{pascal_val}")
417 } else {
418 json_to_csharp(vv)
419 }
420 } else {
421 json_to_csharp(vv)
422 };
423 format!("{pascal_key} = {cs_val}")
424 })
425 .collect();
426 parts.push(format!("new {opts_type} {{ {} }}", props.join(", ")));
427 continue;
428 }
429 }
430 parts.push(json_to_csharp(v));
431 }
432 }
433 }
434
435 (setup_lines, parts.join(", "))
436}
437
438fn render_assertion(
439 out: &mut String,
440 assertion: &Assertion,
441 result_var: &str,
442 field_resolver: &FieldResolver,
443 result_is_simple: bool,
444) {
445 if let Some(f) = &assertion.field {
447 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
448 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
449 return;
450 }
451 }
452
453 let field_expr = if result_is_simple {
454 result_var.to_string()
455 } else {
456 match &assertion.field {
457 Some(f) if !f.is_empty() => field_resolver.accessor(f, "csharp", result_var),
458 _ => result_var.to_string(),
459 }
460 };
461
462 let field_is_optional = assertion
464 .field
465 .as_deref()
466 .map(|f| field_resolver.is_optional(field_resolver.resolve(f)))
467 .unwrap_or(false);
468
469 match assertion.assertion_type.as_str() {
470 "equals" => {
471 if let Some(expected) = &assertion.value {
472 let cs_val = json_to_csharp(expected);
473 if expected.is_string() {
475 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr}.Trim());");
476 } else if expected.is_number() && field_is_optional {
477 let _ = writeln!(out, " Assert.Equal((object?){cs_val}, (object?){field_expr});");
480 } else {
481 let _ = writeln!(out, " Assert.Equal({cs_val}, {field_expr});");
482 }
483 }
484 }
485 "contains" => {
486 if let Some(expected) = &assertion.value {
487 let lower_expected = expected.as_str().map(|s| s.to_lowercase());
492 let cs_val = lower_expected
493 .as_deref()
494 .map(|s| format!("\"{}\"", escape_csharp(s)))
495 .unwrap_or_else(|| json_to_csharp(expected));
496 let _ = writeln!(
497 out,
498 " Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
499 );
500 }
501 }
502 "contains_all" => {
503 if let Some(values) = &assertion.values {
504 for val in values {
505 let lower_val = val.as_str().map(|s| s.to_lowercase());
506 let cs_val = lower_val
507 .as_deref()
508 .map(|s| format!("\"{}\"", escape_csharp(s)))
509 .unwrap_or_else(|| json_to_csharp(val));
510 let _ = writeln!(
511 out,
512 " Assert.Contains({cs_val}, {field_expr}.ToString().ToLower());"
513 );
514 }
515 }
516 }
517 "not_contains" => {
518 if let Some(expected) = &assertion.value {
519 let cs_val = json_to_csharp(expected);
520 let _ = writeln!(out, " Assert.DoesNotContain({cs_val}, {field_expr}.ToString());");
521 }
522 }
523 "not_empty" => {
524 let _ = writeln!(
525 out,
526 " Assert.False(string.IsNullOrEmpty({field_expr}?.ToString()));"
527 );
528 }
529 "is_empty" => {
530 let _ = writeln!(
531 out,
532 " Assert.True(string.IsNullOrEmpty({field_expr}?.ToString()));"
533 );
534 }
535 "contains_any" => {
536 if let Some(values) = &assertion.values {
537 let checks: Vec<String> = values
538 .iter()
539 .map(|v| {
540 let cs_val = json_to_csharp(v);
541 format!("{field_expr}.ToString().Contains({cs_val})")
542 })
543 .collect();
544 let joined = checks.join(" || ");
545 let _ = writeln!(
546 out,
547 " Assert.True({joined}, \"expected to contain at least one of the specified values\");"
548 );
549 }
550 }
551 "greater_than" => {
552 if let Some(val) = &assertion.value {
553 let cs_val = json_to_csharp(val);
554 let _ = writeln!(
555 out,
556 " Assert.True({field_expr} > {cs_val}, \"expected > {cs_val}\");"
557 );
558 }
559 }
560 "less_than" => {
561 if let Some(val) = &assertion.value {
562 let cs_val = json_to_csharp(val);
563 let _ = writeln!(
564 out,
565 " Assert.True({field_expr} < {cs_val}, \"expected < {cs_val}\");"
566 );
567 }
568 }
569 "greater_than_or_equal" => {
570 if let Some(val) = &assertion.value {
571 let cs_val = json_to_csharp(val);
572 let _ = writeln!(
573 out,
574 " Assert.True({field_expr} >= {cs_val}, \"expected >= {cs_val}\");"
575 );
576 }
577 }
578 "less_than_or_equal" => {
579 if let Some(val) = &assertion.value {
580 let cs_val = json_to_csharp(val);
581 let _ = writeln!(
582 out,
583 " Assert.True({field_expr} <= {cs_val}, \"expected <= {cs_val}\");"
584 );
585 }
586 }
587 "starts_with" => {
588 if let Some(expected) = &assertion.value {
589 let cs_val = json_to_csharp(expected);
590 let _ = writeln!(out, " Assert.StartsWith({cs_val}, {field_expr});");
591 }
592 }
593 "ends_with" => {
594 if let Some(expected) = &assertion.value {
595 let cs_val = json_to_csharp(expected);
596 let _ = writeln!(out, " Assert.EndsWith({cs_val}, {field_expr});");
597 }
598 }
599 "min_length" => {
600 if let Some(val) = &assertion.value {
601 if let Some(n) = val.as_u64() {
602 let _ = writeln!(
603 out,
604 " Assert.True({field_expr}.Length >= {n}, \"expected length >= {n}\");"
605 );
606 }
607 }
608 }
609 "max_length" => {
610 if let Some(val) = &assertion.value {
611 if let Some(n) = val.as_u64() {
612 let _ = writeln!(
613 out,
614 " Assert.True({field_expr}.Length <= {n}, \"expected length <= {n}\");"
615 );
616 }
617 }
618 }
619 "count_min" => {
620 if let Some(val) = &assertion.value {
621 if let Some(n) = val.as_u64() {
622 let _ = writeln!(
623 out,
624 " Assert.True({field_expr}.Count >= {n}, \"expected at least {n} elements\");"
625 );
626 }
627 }
628 }
629 "count_equals" => {
630 if let Some(val) = &assertion.value {
631 if let Some(n) = val.as_u64() {
632 let _ = writeln!(out, " Assert.Equal({n}, {field_expr}.Count);");
633 }
634 }
635 }
636 "is_true" => {
637 let _ = writeln!(out, " Assert.True({field_expr});");
638 }
639 "not_error" => {
640 }
642 "error" => {
643 }
645 other => {
646 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
647 }
648 }
649}
650
651fn sort_discriminator_first(value: serde_json::Value) -> serde_json::Value {
658 match value {
659 serde_json::Value::Object(map) => {
660 let mut sorted = serde_json::Map::with_capacity(map.len());
661 if let Some(type_val) = map.get("type") {
663 sorted.insert("type".to_string(), sort_discriminator_first(type_val.clone()));
664 }
665 for (k, v) in map {
666 if k != "type" {
667 sorted.insert(k, sort_discriminator_first(v));
668 }
669 }
670 serde_json::Value::Object(sorted)
671 }
672 serde_json::Value::Array(arr) => {
673 serde_json::Value::Array(arr.into_iter().map(sort_discriminator_first).collect())
674 }
675 other => other,
676 }
677}
678
679fn json_to_csharp(value: &serde_json::Value) -> String {
681 match value {
682 serde_json::Value::String(s) => format!("\"{}\"", escape_csharp(s)),
683 serde_json::Value::Bool(true) => "true".to_string(),
684 serde_json::Value::Bool(false) => "false".to_string(),
685 serde_json::Value::Number(n) => {
686 if n.is_f64() {
687 format!("{}d", n)
688 } else {
689 n.to_string()
690 }
691 }
692 serde_json::Value::Null => "null".to_string(),
693 serde_json::Value::Array(arr) => {
694 let items: Vec<String> = arr.iter().map(json_to_csharp).collect();
695 format!("new[] {{ {} }}", items.join(", "))
696 }
697 serde_json::Value::Object(_) => {
698 let json_str = serde_json::to_string(value).unwrap_or_default();
699 format!("\"{}\"", escape_csharp(&json_str))
700 }
701 }
702}
703
704fn build_csharp_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
710 setup_lines.push("var _testVisitor = new TestVisitor();".to_string());
711 setup_lines.push("class TestVisitor : IVisitor".to_string());
712 setup_lines.push("{".to_string());
713 for (method_name, action) in &visitor_spec.callbacks {
714 emit_csharp_visitor_method(setup_lines, method_name, action);
715 }
716 setup_lines.push("}".to_string());
717 "_testVisitor".to_string()
718}
719
720fn emit_csharp_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
722 let camel_method = method_to_camel(method_name);
723 let params = match method_name {
724 "visit_link" => "VisitContext ctx, string href, string text, string title",
725 "visit_image" => "VisitContext ctx, string src, string alt, string title",
726 "visit_heading" => "VisitContext ctx, int level, string text, string id",
727 "visit_code_block" => "VisitContext ctx, string lang, string code",
728 "visit_code_inline"
729 | "visit_strong"
730 | "visit_emphasis"
731 | "visit_strikethrough"
732 | "visit_underline"
733 | "visit_subscript"
734 | "visit_superscript"
735 | "visit_mark"
736 | "visit_button"
737 | "visit_summary"
738 | "visit_figcaption"
739 | "visit_definition_term"
740 | "visit_definition_description" => "VisitContext ctx, string text",
741 "visit_text" => "VisitContext ctx, string text",
742 "visit_list_item" => "VisitContext ctx, bool ordered, string marker, string text",
743 "visit_blockquote" => "VisitContext ctx, string content, int depth",
744 "visit_table_row" => "VisitContext ctx, IReadOnlyList<string> cells, bool isHeader",
745 "visit_custom_element" => "VisitContext ctx, string tagName, string html",
746 "visit_form" => "VisitContext ctx, string actionUrl, string method",
747 "visit_input" => "VisitContext ctx, string inputType, string name, string value",
748 "visit_audio" | "visit_video" | "visit_iframe" => "VisitContext ctx, string src",
749 "visit_details" => "VisitContext ctx, bool isOpen",
750 _ => "VisitContext ctx",
751 };
752
753 setup_lines.push(format!(" public VisitResult {camel_method}({params})"));
754 setup_lines.push(" {".to_string());
755 match action {
756 CallbackAction::Skip => {
757 setup_lines.push(" return VisitResult.Skip();".to_string());
758 }
759 CallbackAction::Continue => {
760 setup_lines.push(" return VisitResult.Continue();".to_string());
761 }
762 CallbackAction::PreserveHtml => {
763 setup_lines.push(" return VisitResult.PreserveHtml();".to_string());
764 }
765 CallbackAction::Custom { output } => {
766 let escaped = escape_csharp(output);
767 setup_lines.push(format!(" return VisitResult.Custom(\"{escaped}\");"));
768 }
769 CallbackAction::CustomTemplate { template } => {
770 setup_lines.push(format!(" return VisitResult.Custom($\"{template}\");"));
771 }
772 }
773 setup_lines.push(" }".to_string());
774}
775
776fn method_to_camel(snake: &str) -> String {
778 use heck::ToUpperCamelCase;
779 snake.to_upper_camel_case()
780}