1use crate::config::E2eConfig;
8use crate::escape::{escape_php, sanitize_filename};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup};
11use alef_core::backend::GeneratedFile;
12use alef_core::config::AlefConfig;
13use anyhow::Result;
14use heck::{ToSnakeCase, ToUpperCamelCase};
15use std::collections::HashMap;
16use std::fmt::Write as FmtWrite;
17use std::path::PathBuf;
18
19use super::E2eCodegen;
20
21pub struct PhpCodegen;
23
24impl E2eCodegen for PhpCodegen {
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;
40 let overrides = call.overrides.get(lang);
41 let extension_name = alef_config.php_extension_name();
42 let class_name = overrides
43 .and_then(|o| o.class.as_ref())
44 .cloned()
45 .unwrap_or_else(|| extension_name.to_upper_camel_case());
46 let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
47 if extension_name.contains('_') {
48 extension_name
49 .split('_')
50 .map(|p| p.to_upper_camel_case())
51 .collect::<Vec<_>>()
52 .join("\\")
53 } else {
54 extension_name.to_upper_camel_case()
55 }
56 });
57 let empty_enum_fields = HashMap::new();
58 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
59 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
60 let php_client_factory = overrides.and_then(|o| o.php_client_factory.as_deref());
61 let options_via = overrides.and_then(|o| o.options_via.as_deref()).unwrap_or("array");
62
63 let php_pkg = e2e_config.resolve_package("php");
65 let pkg_name = php_pkg
66 .as_ref()
67 .and_then(|p| p.name.as_ref())
68 .cloned()
69 .unwrap_or_else(|| format!("kreuzberg/{}", call.module.replace('_', "-")));
70 let pkg_path = php_pkg
71 .as_ref()
72 .and_then(|p| p.path.as_ref())
73 .cloned()
74 .unwrap_or_else(|| "../../packages/php".to_string());
75 let pkg_version = php_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 files.push(GeneratedFile {
83 path: output_base.join("composer.json"),
84 content: render_composer_json(&pkg_name, &pkg_path, &pkg_version, e2e_config.dep_mode),
85 generated_header: false,
86 });
87
88 files.push(GeneratedFile {
90 path: output_base.join("phpunit.xml"),
91 content: render_phpunit_xml(),
92 generated_header: false,
93 });
94
95 files.push(GeneratedFile {
97 path: output_base.join("bootstrap.php"),
98 content: render_bootstrap(&pkg_path),
99 generated_header: true,
100 });
101
102 let tests_base = output_base.join("tests");
104 let field_resolver = FieldResolver::new(
105 &e2e_config.fields,
106 &e2e_config.fields_optional,
107 &e2e_config.result_fields,
108 &e2e_config.fields_array,
109 );
110
111 for group in groups {
112 let active: Vec<&Fixture> = group
113 .fixtures
114 .iter()
115 .filter(|f| f.skip.as_ref().is_none_or(|s| !s.should_skip(lang)))
116 .collect();
117
118 if active.is_empty() {
119 continue;
120 }
121
122 let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
123 let filename = format!("{test_class}.php");
124 let content = render_test_file(
125 &group.category,
126 &active,
127 e2e_config,
128 lang,
129 &namespace,
130 &class_name,
131 &test_class,
132 &field_resolver,
133 enum_fields,
134 result_is_simple,
135 php_client_factory,
136 options_via,
137 );
138 files.push(GeneratedFile {
139 path: tests_base.join(filename),
140 content,
141 generated_header: true,
142 });
143 }
144
145 Ok(files)
146 }
147
148 fn language_name(&self) -> &'static str {
149 "php"
150 }
151}
152
153fn render_composer_json(
158 pkg_name: &str,
159 _pkg_path: &str,
160 pkg_version: &str,
161 dep_mode: crate::config::DependencyMode,
162) -> String {
163 let require_section = match dep_mode {
164 crate::config::DependencyMode::Registry => {
165 format!(
166 r#" "require": {{
167 "{pkg_name}": "{pkg_version}"
168 }},
169 "require-dev": {{
170 "phpunit/phpunit": "^13.1"
171 }},"#
172 )
173 }
174 crate::config::DependencyMode::Local => r#" "require-dev": {
175 "phpunit/phpunit": "^13.1"
176 },"#
177 .to_string(),
178 };
179
180 format!(
181 r#"{{
182 "name": "kreuzberg/e2e-php",
183 "description": "E2e tests for PHP bindings",
184 "type": "project",
185{require_section}
186 "autoload-dev": {{
187 "psr-4": {{
188 "Kreuzberg\\E2e\\": "tests/"
189 }}
190 }}
191}}
192"#
193 )
194}
195
196fn render_phpunit_xml() -> String {
197 r#"<?xml version="1.0" encoding="UTF-8"?>
198<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
199 xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/13.1/phpunit.xsd"
200 bootstrap="bootstrap.php"
201 colors="true"
202 failOnRisky="true"
203 failOnWarning="true">
204 <testsuites>
205 <testsuite name="e2e">
206 <directory>tests</directory>
207 </testsuite>
208 </testsuites>
209</phpunit>
210"#
211 .to_string()
212}
213
214fn render_bootstrap(pkg_path: &str) -> String {
215 format!(
216 r#"<?php
217// This file is auto-generated by alef. DO NOT EDIT.
218
219declare(strict_types=1);
220
221// Load the e2e project autoloader (PHPUnit, test helpers).
222require_once __DIR__ . '/vendor/autoload.php';
223
224// Load the PHP binding package classes via its Composer autoloader.
225// The package's autoloader is separate from the e2e project's autoloader
226// since the php-ext type prevents direct composer path dependency.
227$pkgAutoloader = __DIR__ . '/{pkg_path}/vendor/autoload.php';
228if (file_exists($pkgAutoloader)) {{
229 require_once $pkgAutoloader;
230}}
231"#
232 )
233}
234
235#[allow(clippy::too_many_arguments)]
236fn render_test_file(
237 category: &str,
238 fixtures: &[&Fixture],
239 e2e_config: &E2eConfig,
240 lang: &str,
241 namespace: &str,
242 class_name: &str,
243 test_class: &str,
244 field_resolver: &FieldResolver,
245 enum_fields: &HashMap<String, String>,
246 result_is_simple: bool,
247 php_client_factory: Option<&str>,
248 options_via: &str,
249) -> String {
250 let mut out = String::new();
251 let _ = writeln!(out, "<?php");
252 let _ = writeln!(out, "// This file is auto-generated by alef. DO NOT EDIT.");
253 let _ = writeln!(out);
254 let _ = writeln!(out, "declare(strict_types=1);");
255 let _ = writeln!(out);
256 let _ = writeln!(out, "namespace Kreuzberg\\E2e;");
257 let _ = writeln!(out);
258 let needs_crawl_config_import = fixtures.iter().any(|f| {
260 let call = e2e_config.resolve_call(f.call.as_deref());
261 call.args.iter().filter(|a| a.arg_type == "handle").any(|a| {
262 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
263 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
264 })
265 });
266
267 let _ = writeln!(out, "use PHPUnit\\Framework\\TestCase;");
268 let _ = writeln!(out, "use {namespace}\\{class_name};");
269 if needs_crawl_config_import {
270 let _ = writeln!(out, "use {namespace}\\CrawlConfig;");
271 }
272 let _ = writeln!(out);
273 let _ = writeln!(out, "/** E2e tests for category: {category}. */");
274 let _ = writeln!(out, "final class {test_class} extends TestCase");
275 let _ = writeln!(out, "{{");
276
277 for (i, fixture) in fixtures.iter().enumerate() {
278 render_test_method(
279 &mut out,
280 fixture,
281 e2e_config,
282 lang,
283 namespace,
284 class_name,
285 field_resolver,
286 enum_fields,
287 result_is_simple,
288 php_client_factory,
289 options_via,
290 );
291 if i + 1 < fixtures.len() {
292 let _ = writeln!(out);
293 }
294 }
295
296 let _ = writeln!(out, "}}");
297 out
298}
299
300#[allow(clippy::too_many_arguments)]
301fn render_test_method(
302 out: &mut String,
303 fixture: &Fixture,
304 e2e_config: &E2eConfig,
305 lang: &str,
306 namespace: &str,
307 class_name: &str,
308 field_resolver: &FieldResolver,
309 enum_fields: &HashMap<String, String>,
310 result_is_simple: bool,
311 php_client_factory: Option<&str>,
312 options_via: &str,
313) {
314 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
316 let call_overrides = call_config.overrides.get(lang);
317 let mut function_name = call_overrides
318 .and_then(|o| o.function.as_ref())
319 .cloned()
320 .unwrap_or_else(|| call_config.function.clone());
321 if call_config.r#async {
323 function_name = format!("{function_name}_async");
324 }
325 let result_var = &call_config.result_var;
326 let args = &call_config.args;
327
328 let method_name = sanitize_filename(&fixture.id);
329 let description = &fixture.description;
330 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
331
332 let (mut setup_lines, args_str) =
333 build_args_and_setup(&fixture.input, args, class_name, enum_fields, &fixture.id, options_via);
334
335 let mut visitor_arg = String::new();
337 if let Some(visitor_spec) = &fixture.visitor {
338 visitor_arg = build_php_visitor(&mut setup_lines, visitor_spec);
339 }
340
341 let final_args = if visitor_arg.is_empty() {
342 args_str
343 } else if args_str.is_empty() {
344 visitor_arg
345 } else {
346 format!("{args_str}, {visitor_arg}")
347 };
348
349 let call_expr = if php_client_factory.is_some() {
350 format!("$client->{function_name}({final_args})")
351 } else {
352 format!("{class_name}::{function_name}({final_args})")
353 };
354
355 let _ = writeln!(out, " /** {description} */");
356 let _ = writeln!(out, " public function test_{method_name}(): void");
357 let _ = writeln!(out, " {{");
358
359 if let Some(factory) = php_client_factory {
360 let _ = writeln!(
361 out,
362 " $client = \\{namespace}\\{class_name}::{factory}('test-key');"
363 );
364 }
365
366 for line in &setup_lines {
367 let _ = writeln!(out, " {line}");
368 }
369
370 if expects_error {
371 let _ = writeln!(out, " $this->expectException(\\Exception::class);");
372 let _ = writeln!(out, " {call_expr};");
373 let _ = writeln!(out, " }}");
374 return;
375 }
376
377 let has_usable = fixture.assertions.iter().any(|a| {
380 if a.assertion_type == "error" || a.assertion_type == "not_error" {
381 return false;
382 }
383 match &a.field {
384 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
385 _ => true,
386 }
387 });
388 if !has_usable {
389 let _ = writeln!(out, " $this->expectNotToPerformAssertions();");
390 }
391
392 let _ = writeln!(out, " ${result_var} = {call_expr};");
393
394 for assertion in &fixture.assertions {
395 render_assertion(out, assertion, result_var, field_resolver, result_is_simple);
396 }
397
398 let _ = writeln!(out, " }}");
399}
400
401fn build_args_and_setup(
409 input: &serde_json::Value,
410 args: &[crate::config::ArgMapping],
411 class_name: &str,
412 enum_fields: &HashMap<String, String>,
413 fixture_id: &str,
414 options_via: &str,
415) -> (Vec<String>, String) {
416 if args.is_empty() {
417 let is_empty_input = match input {
420 serde_json::Value::Null => true,
421 serde_json::Value::Object(m) => m.is_empty(),
422 _ => false,
423 };
424 if is_empty_input {
425 return (Vec::new(), String::new());
426 }
427 return (Vec::new(), json_to_php(input));
428 }
429
430 let mut setup_lines: Vec<String> = Vec::new();
431 let mut parts: Vec<String> = Vec::new();
432
433 for arg in args {
434 if arg.arg_type == "mock_url" {
435 setup_lines.push(format!(
436 "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
437 arg.name,
438 ));
439 parts.push(format!("${}", arg.name));
440 continue;
441 }
442
443 if arg.arg_type == "handle" {
444 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
446 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
447 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
448 if config_value.is_null()
449 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
450 {
451 setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
452 } else {
453 let name = &arg.name;
454 setup_lines.push(format!("${name}_config = CrawlConfig::default();"));
458 if let Some(obj) = config_value.as_object() {
459 for (key, val) in obj {
460 let php_val = json_to_php(val);
461 setup_lines.push(format!("${name}_config->{key} = {php_val};"));
462 }
463 }
464 setup_lines.push(format!(
465 "${} = {class_name}::{constructor_name}(${name}_config);",
466 arg.name,
467 ));
468 }
469 parts.push(format!("${}", arg.name));
470 continue;
471 }
472
473 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
474 let val = input.get(field);
475 match val {
476 None | Some(serde_json::Value::Null) if arg.optional => {
477 continue;
479 }
480 None | Some(serde_json::Value::Null) => {
481 let default_val = match arg.arg_type.as_str() {
483 "string" => "\"\"".to_string(),
484 "int" | "integer" => "0".to_string(),
485 "float" | "number" => "0.0".to_string(),
486 "bool" | "boolean" => "false".to_string(),
487 "json_object" if options_via == "json" => "null".to_string(),
488 _ => "null".to_string(),
489 };
490 parts.push(default_val);
491 }
492 Some(v) => {
493 if arg.arg_type == "json_object" && !v.is_null() {
494 match options_via {
495 "json" => {
496 parts.push(format!("json_encode({})", json_to_php(v)));
498 continue;
499 }
500 _ => {
501 if let Some(obj) = v.as_object() {
503 let items: Vec<String> = obj
504 .iter()
505 .map(|(k, vv)| {
506 let snake_key = k.to_snake_case();
507 let php_val = if enum_fields.contains_key(k) {
508 if let Some(s) = vv.as_str() {
509 let snake_val = s.to_snake_case();
510 format!("\"{}\"", escape_php(&snake_val))
511 } else {
512 json_to_php(vv)
513 }
514 } else {
515 json_to_php(vv)
516 };
517 format!("\"{}\" => {}", escape_php(&snake_key), php_val)
518 })
519 .collect();
520 parts.push(format!("[{}]", items.join(", ")));
521 continue;
522 }
523 }
524 }
525 }
526 parts.push(json_to_php(v));
527 }
528 }
529 }
530
531 (setup_lines, parts.join(", "))
532}
533
534fn render_assertion(
535 out: &mut String,
536 assertion: &Assertion,
537 result_var: &str,
538 field_resolver: &FieldResolver,
539 result_is_simple: bool,
540) {
541 if let Some(f) = &assertion.field {
543 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
544 let _ = writeln!(out, " // skipped: field '{f}' not available on result type");
545 return;
546 }
547 }
548
549 if result_is_simple {
552 if let Some(f) = &assertion.field {
553 let f_lower = f.to_lowercase();
554 if !f.is_empty()
555 && f_lower != "content"
556 && (f_lower.starts_with("metadata")
557 || f_lower.starts_with("document")
558 || f_lower.starts_with("structure"))
559 {
560 let _ = writeln!(out, " // TODO: skipped (result_is_simple, field: {f})");
561 return;
562 }
563 }
564 }
565
566 let field_expr = if result_is_simple {
567 format!("${result_var}")
568 } else {
569 match &assertion.field {
570 Some(f) if !f.is_empty() => field_resolver.accessor(f, "php", &format!("${result_var}")),
571 _ => format!("${result_var}"),
572 }
573 };
574
575 let trimmed_field_expr = if result_is_simple {
577 format!("trim(${result_var})")
578 } else {
579 field_expr.clone()
580 };
581
582 match assertion.assertion_type.as_str() {
583 "equals" => {
584 if let Some(expected) = &assertion.value {
585 let php_val = json_to_php(expected);
586 let _ = writeln!(out, " $this->assertEquals({php_val}, {trimmed_field_expr});");
587 }
588 }
589 "contains" => {
590 if let Some(expected) = &assertion.value {
591 let php_val = json_to_php(expected);
592 let _ = writeln!(
593 out,
594 " $this->assertStringContainsString({php_val}, {field_expr});"
595 );
596 }
597 }
598 "contains_all" => {
599 if let Some(values) = &assertion.values {
600 for val in values {
601 let php_val = json_to_php(val);
602 let _ = writeln!(
603 out,
604 " $this->assertStringContainsString({php_val}, {field_expr});"
605 );
606 }
607 }
608 }
609 "not_contains" => {
610 if let Some(expected) = &assertion.value {
611 let php_val = json_to_php(expected);
612 let _ = writeln!(
613 out,
614 " $this->assertStringNotContainsString({php_val}, {field_expr});"
615 );
616 }
617 }
618 "not_empty" => {
619 let _ = writeln!(out, " $this->assertNotEmpty({field_expr});");
620 }
621 "is_empty" => {
622 let _ = writeln!(out, " $this->assertEmpty({trimmed_field_expr});");
623 }
624 "contains_any" => {
625 if let Some(values) = &assertion.values {
626 let _ = writeln!(out, " $found = false;");
627 for val in values {
628 let php_val = json_to_php(val);
629 let _ = writeln!(
630 out,
631 " if (str_contains({field_expr}, {php_val})) {{ $found = true; }}"
632 );
633 }
634 let _ = writeln!(
635 out,
636 " $this->assertTrue($found, 'expected to contain at least one of the specified values');"
637 );
638 }
639 }
640 "greater_than" => {
641 if let Some(val) = &assertion.value {
642 let php_val = json_to_php(val);
643 let _ = writeln!(out, " $this->assertGreaterThan({php_val}, {field_expr});");
644 }
645 }
646 "less_than" => {
647 if let Some(val) = &assertion.value {
648 let php_val = json_to_php(val);
649 let _ = writeln!(out, " $this->assertLessThan({php_val}, {field_expr});");
650 }
651 }
652 "greater_than_or_equal" => {
653 if let Some(val) = &assertion.value {
654 let php_val = json_to_php(val);
655 let _ = writeln!(out, " $this->assertGreaterThanOrEqual({php_val}, {field_expr});");
656 }
657 }
658 "less_than_or_equal" => {
659 if let Some(val) = &assertion.value {
660 let php_val = json_to_php(val);
661 let _ = writeln!(out, " $this->assertLessThanOrEqual({php_val}, {field_expr});");
662 }
663 }
664 "starts_with" => {
665 if let Some(expected) = &assertion.value {
666 let php_val = json_to_php(expected);
667 let _ = writeln!(out, " $this->assertStringStartsWith({php_val}, {field_expr});");
668 }
669 }
670 "ends_with" => {
671 if let Some(expected) = &assertion.value {
672 let php_val = json_to_php(expected);
673 let _ = writeln!(out, " $this->assertStringEndsWith({php_val}, {field_expr});");
674 }
675 }
676 "min_length" => {
677 if let Some(val) = &assertion.value {
678 if let Some(n) = val.as_u64() {
679 let _ = writeln!(
680 out,
681 " $this->assertGreaterThanOrEqual({n}, strlen({field_expr}));"
682 );
683 }
684 }
685 }
686 "max_length" => {
687 if let Some(val) = &assertion.value {
688 if let Some(n) = val.as_u64() {
689 let _ = writeln!(out, " $this->assertLessThanOrEqual({n}, strlen({field_expr}));");
690 }
691 }
692 }
693 "count_min" => {
694 if let Some(val) = &assertion.value {
695 if let Some(n) = val.as_u64() {
696 let _ = writeln!(
697 out,
698 " $this->assertGreaterThanOrEqual({n}, count({field_expr}));"
699 );
700 }
701 }
702 }
703 "count_equals" => {
704 if let Some(val) = &assertion.value {
705 if let Some(n) = val.as_u64() {
706 let _ = writeln!(out, " $this->assertCount({n}, {field_expr});");
707 }
708 }
709 }
710 "is_true" => {
711 let _ = writeln!(out, " $this->assertTrue({field_expr});");
712 }
713 "not_error" => {
714 }
716 "error" => {
717 }
719 other => {
720 let _ = writeln!(out, " // TODO: unsupported assertion type: {other}");
721 }
722 }
723}
724
725fn json_to_php(value: &serde_json::Value) -> String {
727 match value {
728 serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
729 serde_json::Value::Bool(true) => "true".to_string(),
730 serde_json::Value::Bool(false) => "false".to_string(),
731 serde_json::Value::Number(n) => n.to_string(),
732 serde_json::Value::Null => "null".to_string(),
733 serde_json::Value::Array(arr) => {
734 let items: Vec<String> = arr.iter().map(json_to_php).collect();
735 format!("[{}]", items.join(", "))
736 }
737 serde_json::Value::Object(map) => {
738 let items: Vec<String> = map
739 .iter()
740 .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
741 .collect();
742 format!("[{}]", items.join(", "))
743 }
744 }
745}
746
747fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) -> String {
753 setup_lines.push("$visitor = new class {".to_string());
754 for (method_name, action) in &visitor_spec.callbacks {
755 emit_php_visitor_method(setup_lines, method_name, action);
756 }
757 setup_lines.push("};".to_string());
758 "$visitor".to_string()
759}
760
761fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
763 let snake_method = method_name;
764 let params = match method_name {
765 "visit_link" => "$ctx, $href, $text, $title",
766 "visit_image" => "$ctx, $src, $alt, $title",
767 "visit_heading" => "$ctx, $level, $text, $id",
768 "visit_code_block" => "$ctx, $lang, $code",
769 "visit_code_inline"
770 | "visit_strong"
771 | "visit_emphasis"
772 | "visit_strikethrough"
773 | "visit_underline"
774 | "visit_subscript"
775 | "visit_superscript"
776 | "visit_mark"
777 | "visit_button"
778 | "visit_summary"
779 | "visit_figcaption"
780 | "visit_definition_term"
781 | "visit_definition_description" => "$ctx, $text",
782 "visit_text" => "$ctx, $text",
783 "visit_list_item" => "$ctx, $ordered, $marker, $text",
784 "visit_blockquote" => "$ctx, $content, $depth",
785 "visit_table_row" => "$ctx, $cells, $isHeader",
786 "visit_custom_element" => "$ctx, $tagName, $html",
787 "visit_form" => "$ctx, $actionUrl, $method",
788 "visit_input" => "$ctx, $inputType, $name, $value",
789 "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
790 "visit_details" => "$ctx, $isOpen",
791 _ => "$ctx",
792 };
793
794 setup_lines.push(format!(" public function {snake_method}({params}) {{"));
795 match action {
796 CallbackAction::Skip => {
797 setup_lines.push(" return 'skip';".to_string());
798 }
799 CallbackAction::Continue => {
800 setup_lines.push(" return 'continue';".to_string());
801 }
802 CallbackAction::PreserveHtml => {
803 setup_lines.push(" return 'preserve_html';".to_string());
804 }
805 CallbackAction::Custom { output } => {
806 let escaped = escape_php(output);
807 setup_lines.push(format!(" return ['custom' => {escaped}];"));
808 }
809 CallbackAction::CustomTemplate { template } => {
810 setup_lines.push(format!(" return ['custom' => \"{template}\"];"));
811 }
812 }
813 setup_lines.push(" }".to_string());
814}