1use crate::config::E2eConfig;
8use crate::escape::{escape_php, sanitize_filename};
9use crate::field_access::FieldResolver;
10use crate::fixture::{Assertion, CallbackAction, Fixture, FixtureGroup, HttpFixture, ValidationErrorExpectation};
11use alef_backend_php::naming::php_autoload_namespace;
12use alef_core::backend::GeneratedFile;
13use alef_core::config::ResolvedCrateConfig;
14use alef_core::hash::{self, CommentStyle};
15use alef_core::template_versions as tv;
16use anyhow::Result;
17use heck::{ToLowerCamelCase, ToSnakeCase, ToUpperCamelCase};
18use std::collections::HashMap;
19use std::fmt::Write as FmtWrite;
20use std::path::PathBuf;
21
22use super::E2eCodegen;
23use super::client;
24
25pub struct PhpCodegen;
27
28impl E2eCodegen for PhpCodegen {
29 fn generate(
30 &self,
31 groups: &[FixtureGroup],
32 e2e_config: &E2eConfig,
33 config: &ResolvedCrateConfig,
34 ) -> Result<Vec<GeneratedFile>> {
35 let lang = self.language_name();
36 let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
37
38 let mut files = Vec::new();
39
40 let call = &e2e_config.call;
44 let overrides = call.overrides.get(lang);
45 let extension_name = config.php_extension_name();
46 let class_name = overrides
47 .and_then(|o| o.class.as_ref())
48 .cloned()
49 .map(|cn| cn.split('\\').next_back().unwrap_or(&cn).to_string())
50 .unwrap_or_else(|| extension_name.to_upper_camel_case());
51 let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
52 if extension_name.contains('_') {
53 extension_name
54 .split('_')
55 .map(|p| p.to_upper_camel_case())
56 .collect::<Vec<_>>()
57 .join("\\")
58 } else {
59 extension_name.to_upper_camel_case()
60 }
61 });
62 let empty_enum_fields = HashMap::new();
63 let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
64 let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
65 let php_client_factory = overrides.and_then(|o| o.php_client_factory.as_deref());
66 let options_via = overrides.and_then(|o| o.options_via.as_deref()).unwrap_or("array");
67
68 let php_pkg = e2e_config.resolve_package("php");
70 let pkg_name = php_pkg
71 .as_ref()
72 .and_then(|p| p.name.as_ref())
73 .cloned()
74 .unwrap_or_else(|| {
75 let org = config
78 .try_github_repo()
79 .ok()
80 .as_deref()
81 .and_then(alef_core::config::derive_repo_org)
82 .unwrap_or_else(|| config.name.clone());
83 format!("{org}/{}", call.module.replace('_', "-"))
84 });
85 let pkg_path = php_pkg
86 .as_ref()
87 .and_then(|p| p.path.as_ref())
88 .cloned()
89 .unwrap_or_else(|| "../../packages/php".to_string());
90 let pkg_version = php_pkg
91 .as_ref()
92 .and_then(|p| p.version.as_ref())
93 .cloned()
94 .or_else(|| config.resolved_version())
95 .unwrap_or_else(|| "0.1.0".to_string());
96
97 let e2e_vendor = pkg_name.split('/').next().unwrap_or(&pkg_name).to_string();
102 let e2e_pkg_name = format!("{e2e_vendor}/e2e-php");
103 let php_namespace_escaped = php_autoload_namespace(config).replace('\\', "\\\\");
108 let e2e_autoload_ns = format!("{php_namespace_escaped}\\\\E2e\\\\");
109
110 files.push(GeneratedFile {
112 path: output_base.join("composer.json"),
113 content: render_composer_json(
114 &e2e_pkg_name,
115 &e2e_autoload_ns,
116 &pkg_name,
117 &pkg_path,
118 &pkg_version,
119 e2e_config.dep_mode,
120 ),
121 generated_header: false,
122 });
123
124 files.push(GeneratedFile {
126 path: output_base.join("phpunit.xml"),
127 content: render_phpunit_xml(),
128 generated_header: false,
129 });
130
131 let has_http_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| f.is_http_test());
133
134 let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
136 let cc = e2e_config.resolve_call(f.call.as_deref());
137 cc.args
138 .iter()
139 .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
140 });
141
142 files.push(GeneratedFile {
144 path: output_base.join("bootstrap.php"),
145 content: render_bootstrap(&pkg_path, has_http_fixtures, has_file_fixtures),
146 generated_header: true,
147 });
148
149 files.push(GeneratedFile {
151 path: output_base.join("run_tests.php"),
152 content: render_run_tests_php(&extension_name, config.php_cargo_crate_name()),
153 generated_header: true,
154 });
155
156 let tests_base = output_base.join("tests");
158 let field_resolver = FieldResolver::new(
159 &e2e_config.fields,
160 &e2e_config.fields_optional,
161 &e2e_config.result_fields,
162 &e2e_config.fields_array,
163 &std::collections::HashSet::new(),
164 );
165
166 for group in groups {
167 let active: Vec<&Fixture> = group
168 .fixtures
169 .iter()
170 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
171 .collect();
172
173 if active.is_empty() {
174 continue;
175 }
176
177 let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
178 let filename = format!("{test_class}.php");
179 let content = render_test_file(
180 &group.category,
181 &active,
182 e2e_config,
183 lang,
184 &namespace,
185 &class_name,
186 &test_class,
187 &field_resolver,
188 enum_fields,
189 result_is_simple,
190 php_client_factory,
191 options_via,
192 );
193 files.push(GeneratedFile {
194 path: tests_base.join(filename),
195 content,
196 generated_header: true,
197 });
198 }
199
200 Ok(files)
201 }
202
203 fn language_name(&self) -> &'static str {
204 "php"
205 }
206}
207
208fn render_composer_json(
213 e2e_pkg_name: &str,
214 e2e_autoload_ns: &str,
215 pkg_name: &str,
216 pkg_path: &str,
217 pkg_version: &str,
218 dep_mode: crate::config::DependencyMode,
219) -> String {
220 let (require_section, autoload_section) = match dep_mode {
221 crate::config::DependencyMode::Registry => {
222 let require = format!(
223 r#" "require": {{
224 "{pkg_name}": "{pkg_version}"
225 }},
226 "require-dev": {{
227 "phpunit/phpunit": "{phpunit}",
228 "guzzlehttp/guzzle": "{guzzle}"
229 }},"#,
230 phpunit = tv::packagist::PHPUNIT,
231 guzzle = tv::packagist::GUZZLE,
232 );
233 (require, String::new())
234 }
235 crate::config::DependencyMode::Local => {
236 let require = format!(
237 r#" "require-dev": {{
238 "phpunit/phpunit": "{phpunit}",
239 "guzzlehttp/guzzle": "{guzzle}"
240 }},"#,
241 phpunit = tv::packagist::PHPUNIT,
242 guzzle = tv::packagist::GUZZLE,
243 );
244 let pkg_namespace = pkg_name
247 .split('/')
248 .nth(1)
249 .unwrap_or(pkg_name)
250 .split('-')
251 .map(heck::ToUpperCamelCase::to_upper_camel_case)
252 .collect::<Vec<_>>()
253 .join("\\");
254 let autoload = format!(
255 r#"
256 "autoload": {{
257 "psr-4": {{
258 "{}\\": "{}/src/"
259 }}
260 }},"#,
261 pkg_namespace.replace('\\', "\\\\"),
262 pkg_path
263 );
264 (require, autoload)
265 }
266 };
267
268 crate::template_env::render(
269 "php/composer.json.jinja",
270 minijinja::context! {
271 e2e_pkg_name => e2e_pkg_name,
272 e2e_autoload_ns => e2e_autoload_ns,
273 require_section => require_section,
274 autoload_section => autoload_section,
275 },
276 )
277}
278
279fn render_phpunit_xml() -> String {
280 crate::template_env::render("php/phpunit.xml.jinja", minijinja::context! {})
281}
282
283fn render_bootstrap(pkg_path: &str, has_http_fixtures: bool, has_file_fixtures: bool) -> String {
284 let header = hash::header(CommentStyle::DoubleSlash);
285 crate::template_env::render(
286 "php/bootstrap.php.jinja",
287 minijinja::context! {
288 header => header,
289 pkg_path => pkg_path,
290 has_http_fixtures => has_http_fixtures,
291 has_file_fixtures => has_file_fixtures,
292 },
293 )
294}
295
296fn render_run_tests_php(extension_name: &str, cargo_crate_name: Option<&str>) -> String {
297 let header = hash::header(CommentStyle::DoubleSlash);
298 let ext_lib_name = if let Some(crate_name) = cargo_crate_name {
299 format!("lib{}", crate_name.replace('-', "_"))
302 } else {
303 format!("lib{extension_name}_php")
304 };
305 format!(
306 r#"#!/usr/bin/env php
307<?php
308{header}
309declare(strict_types=1);
310
311// Determine platform-specific extension suffix.
312$extSuffix = match (PHP_OS_FAMILY) {{
313 'Darwin' => '.dylib',
314 default => '.so',
315}};
316$extPath = __DIR__ . '/../../target/release/{ext_lib_name}' . $extSuffix;
317
318// If the locally-built extension exists and we have not already restarted with it,
319// re-exec PHP with no system ini (-n) to avoid conflicts with any system-installed
320// version of the extension, then load the local build explicitly.
321if (file_exists($extPath) && !getenv('ALEF_PHP_LOCAL_EXT_LOADED')) {{
322 putenv('ALEF_PHP_LOCAL_EXT_LOADED=1');
323 $php = PHP_BINARY;
324 $phpunitPath = __DIR__ . '/vendor/bin/phpunit';
325
326 $cmd = array_merge(
327 [$php, '-n', '-d', 'extension=' . $extPath],
328 [$phpunitPath],
329 array_slice($GLOBALS['argv'], 1)
330 );
331
332 passthru(implode(' ', array_map('escapeshellarg', $cmd)), $exitCode);
333 exit($exitCode);
334}}
335
336// Extension is now loaded (via the restart above with -n flag).
337// Invoke PHPUnit normally.
338$phpunitPath = __DIR__ . '/vendor/bin/phpunit';
339if (!file_exists($phpunitPath)) {{
340 echo "PHPUnit not found at $phpunitPath. Run 'composer install' first.\n";
341 exit(1);
342}}
343
344require $phpunitPath;
345"#
346 )
347}
348
349#[allow(clippy::too_many_arguments)]
350fn render_test_file(
351 category: &str,
352 fixtures: &[&Fixture],
353 e2e_config: &E2eConfig,
354 lang: &str,
355 namespace: &str,
356 class_name: &str,
357 test_class: &str,
358 field_resolver: &FieldResolver,
359 enum_fields: &HashMap<String, String>,
360 result_is_simple: bool,
361 php_client_factory: Option<&str>,
362 options_via: &str,
363) -> String {
364 let header = hash::header(CommentStyle::DoubleSlash);
365
366 let needs_crawl_config_import = fixtures.iter().any(|f| {
368 let call = e2e_config.resolve_call(f.call.as_deref());
369 call.args.iter().filter(|a| a.arg_type == "handle").any(|a| {
370 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
371 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
372 })
373 });
374
375 let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
377
378 let mut options_type_imports: Vec<String> = fixtures
380 .iter()
381 .flat_map(|f| {
382 let call = e2e_config.resolve_call(f.call.as_deref());
383 let php_override = call.overrides.get(lang);
384 let opt_type = php_override.and_then(|o| o.options_type.as_deref()).or_else(|| {
385 e2e_config
386 .call
387 .overrides
388 .get(lang)
389 .and_then(|o| o.options_type.as_deref())
390 });
391 let element_types: Vec<String> = call
392 .args
393 .iter()
394 .filter_map(|a| a.element_type.as_ref().map(|t| t.to_string()))
395 .filter(|t| !is_php_reserved_type(t))
396 .collect();
397 opt_type.map(|t| t.to_string()).into_iter().chain(element_types)
398 })
399 .collect::<std::collections::HashSet<_>>()
400 .into_iter()
401 .collect();
402 options_type_imports.sort();
403
404 let mut imports_use: Vec<String> = Vec::new();
406 if needs_crawl_config_import {
407 imports_use.push(format!("use {namespace}\\CrawlConfig;"));
408 }
409 for type_name in &options_type_imports {
410 if type_name != class_name {
411 imports_use.push(format!("use {namespace}\\{type_name};"));
412 }
413 }
414
415 let mut fixtures_body = String::new();
417 for (i, fixture) in fixtures.iter().enumerate() {
418 if fixture.is_http_test() {
419 render_http_test_method(&mut fixtures_body, fixture, fixture.http.as_ref().unwrap());
420 } else {
421 render_test_method(
422 &mut fixtures_body,
423 fixture,
424 e2e_config,
425 lang,
426 namespace,
427 class_name,
428 field_resolver,
429 enum_fields,
430 result_is_simple,
431 php_client_factory,
432 options_via,
433 );
434 }
435 if i + 1 < fixtures.len() {
436 fixtures_body.push('\n');
437 }
438 }
439
440 crate::template_env::render(
441 "php/test_file.jinja",
442 minijinja::context! {
443 header => header,
444 namespace => namespace,
445 class_name => class_name,
446 test_class => test_class,
447 category => category,
448 imports_use => imports_use,
449 has_http_tests => has_http_tests,
450 fixtures_body => fixtures_body,
451 },
452 )
453}
454
455struct PhpTestClientRenderer;
463
464impl client::TestClientRenderer for PhpTestClientRenderer {
465 fn language_name(&self) -> &'static str {
466 "php"
467 }
468
469 fn sanitize_test_name(&self, id: &str) -> String {
471 sanitize_filename(id)
472 }
473
474 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
480 let escaped_reason = skip_reason.map(escape_php);
481 let rendered = crate::template_env::render(
482 "php/http_test_open.jinja",
483 minijinja::context! {
484 fn_name => fn_name,
485 description => description,
486 skip_reason => escaped_reason,
487 },
488 );
489 out.push_str(&rendered);
490 }
491
492 fn render_test_close(&self, out: &mut String) {
494 let rendered = crate::template_env::render("php/http_test_close.jinja", minijinja::context! {});
495 out.push_str(&rendered);
496 }
497
498 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
503 let method = ctx.method.to_uppercase();
504
505 let mut opts: Vec<String> = Vec::new();
507
508 if let Some(body) = ctx.body {
509 let php_body = json_to_php(body);
510 opts.push(format!("'json' => {php_body}"));
511 }
512
513 let mut header_pairs: Vec<String> = Vec::new();
515 if let Some(ct) = ctx.content_type {
516 if !ctx.headers.keys().any(|k| k.to_lowercase() == "content-type") {
518 header_pairs.push(format!("\"Content-Type\" => \"{}\"", escape_php(ct)));
519 }
520 }
521 for (k, v) in ctx.headers {
522 header_pairs.push(format!("\"{}\" => \"{}\"", escape_php(k), escape_php(v)));
523 }
524 if !header_pairs.is_empty() {
525 opts.push(format!("'headers' => [{}]", header_pairs.join(", ")));
526 }
527
528 if !ctx.cookies.is_empty() {
529 let cookie_str = ctx
530 .cookies
531 .iter()
532 .map(|(k, v)| format!("{}={}", k, v))
533 .collect::<Vec<_>>()
534 .join("; ");
535 opts.push(format!("'headers' => ['Cookie' => \"{}\"]", escape_php(&cookie_str)));
536 }
537
538 if !ctx.query_params.is_empty() {
539 let pairs: Vec<String> = ctx
540 .query_params
541 .iter()
542 .map(|(k, v)| {
543 let val_str = match v {
544 serde_json::Value::String(s) => s.clone(),
545 other => other.to_string(),
546 };
547 format!("\"{}\" => \"{}\"", escape_php(k), escape_php(&val_str))
548 })
549 .collect();
550 opts.push(format!("'query' => [{}]", pairs.join(", ")));
551 }
552
553 let path_lit = format!("\"{}\"", escape_php(ctx.path));
554
555 let rendered = crate::template_env::render(
556 "php/http_request.jinja",
557 minijinja::context! {
558 method => method,
559 path => path_lit,
560 opts => opts,
561 response_var => ctx.response_var,
562 },
563 );
564 out.push_str(&rendered);
565 }
566
567 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
569 let rendered = crate::template_env::render(
570 "php/http_assertions.jinja",
571 minijinja::context! {
572 response_var => "",
573 status_code => status,
574 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
575 body_assertion => String::new(),
576 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
577 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
578 },
579 );
580 out.push_str(&rendered);
581 }
582
583 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
588 let header_key = name.to_lowercase();
589 let header_key_lit = format!("\"{}\"", escape_php(&header_key));
590 let assertion_code = match expected {
591 "<<present>>" => {
592 format!("$this->assertTrue($response->hasHeader({header_key_lit}));")
593 }
594 "<<absent>>" => {
595 format!("$this->assertFalse($response->hasHeader({header_key_lit}));")
596 }
597 "<<uuid>>" => {
598 format!(
599 "$this->assertMatchesRegularExpression('/^[0-9a-f]{{8}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{4}}-[0-9a-f]{{12}}$/i', $response->getHeaderLine({header_key_lit}));"
600 )
601 }
602 literal => {
603 let val_lit = format!("\"{}\"", escape_php(literal));
604 format!("$this->assertEquals({val_lit}, $response->getHeaderLine({header_key_lit}));")
605 }
606 };
607
608 let mut headers = vec![std::collections::HashMap::new()];
609 headers[0].insert("assertion_code", assertion_code);
610
611 let rendered = crate::template_env::render(
612 "php/http_assertions.jinja",
613 minijinja::context! {
614 response_var => "",
615 status_code => 0u16,
616 headers => headers,
617 body_assertion => String::new(),
618 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
619 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
620 },
621 );
622 out.push_str(&rendered);
623 }
624
625 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
631 let body_assertion = match expected {
632 serde_json::Value::String(s) if !s.is_empty() => {
633 let php_val = format!("\"{}\"", escape_php(s));
634 format!("$this->assertEquals({php_val}, (string) $response->getBody());")
635 }
636 _ => {
637 let php_val = json_to_php(expected);
638 format!(
639 "$body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);\n $this->assertEquals({php_val}, $body);"
640 )
641 }
642 };
643
644 let rendered = crate::template_env::render(
645 "php/http_assertions.jinja",
646 minijinja::context! {
647 response_var => "",
648 status_code => 0u16,
649 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
650 body_assertion => body_assertion,
651 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
652 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
653 },
654 );
655 out.push_str(&rendered);
656 }
657
658 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
660 if let Some(obj) = expected.as_object() {
661 let mut partial_body: Vec<std::collections::HashMap<&str, String>> = Vec::new();
662 for (key, val) in obj {
663 let php_key = format!("\"{}\"", escape_php(key));
664 let php_val = json_to_php(val);
665 let assertion_code = format!("$this->assertEquals({php_val}, $body[{php_key}]);");
666 let mut entry = std::collections::HashMap::new();
667 entry.insert("assertion_code", assertion_code);
668 partial_body.push(entry);
669 }
670
671 let rendered = crate::template_env::render(
672 "php/http_assertions.jinja",
673 minijinja::context! {
674 response_var => "",
675 status_code => 0u16,
676 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
677 body_assertion => String::new(),
678 partial_body => partial_body,
679 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
680 },
681 );
682 out.push_str(&rendered);
683 }
684 }
685
686 fn render_assert_validation_errors(
689 &self,
690 out: &mut String,
691 _response_var: &str,
692 errors: &[ValidationErrorExpectation],
693 ) {
694 let mut validation_errors: Vec<std::collections::HashMap<&str, String>> = Vec::new();
695 for err in errors {
696 let msg_lit = format!("\"{}\"", escape_php(&err.msg));
697 let assertion_code =
698 format!("$this->assertStringContainsString({msg_lit}, json_encode($body, JSON_UNESCAPED_SLASHES));");
699 let mut entry = std::collections::HashMap::new();
700 entry.insert("assertion_code", assertion_code);
701 validation_errors.push(entry);
702 }
703
704 let rendered = crate::template_env::render(
705 "php/http_assertions.jinja",
706 minijinja::context! {
707 response_var => "",
708 status_code => 0u16,
709 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
710 body_assertion => String::new(),
711 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
712 validation_errors => validation_errors,
713 },
714 );
715 out.push_str(&rendered);
716 }
717}
718
719fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
724 if http.expected_response.status_code == 101 {
728 let method_name = sanitize_filename(&fixture.id);
729 let description = &fixture.description;
730 out.push_str(&crate::template_env::render(
731 "php/http_test_skip_101.jinja",
732 minijinja::context! {
733 method_name => method_name,
734 description => description,
735 },
736 ));
737 return;
738 }
739
740 client::http_call::render_http_test(out, &PhpTestClientRenderer, fixture);
741}
742
743#[allow(clippy::too_many_arguments)]
748fn render_test_method(
749 out: &mut String,
750 fixture: &Fixture,
751 e2e_config: &E2eConfig,
752 lang: &str,
753 namespace: &str,
754 class_name: &str,
755 field_resolver: &FieldResolver,
756 enum_fields: &HashMap<String, String>,
757 result_is_simple: bool,
758 php_client_factory: Option<&str>,
759 options_via: &str,
760) {
761 let call_config = e2e_config.resolve_call(fixture.call.as_deref());
763 let call_overrides = call_config.overrides.get(lang);
764 let has_override = call_overrides.is_some_and(|o| o.function.is_some());
765 let mut function_name = call_overrides
766 .and_then(|o| o.function.as_ref())
767 .cloned()
768 .unwrap_or_else(|| call_config.function.clone());
769 if !has_override {
770 function_name = function_name.to_lower_camel_case();
771 }
772 let result_var = &call_config.result_var;
773 let args = &call_config.args;
774
775 let method_name = sanitize_filename(&fixture.id);
776 let description = &fixture.description;
777 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
778
779 let call_options_type = call_overrides.and_then(|o| o.options_type.as_deref()).or_else(|| {
781 e2e_config
782 .call
783 .overrides
784 .get(lang)
785 .and_then(|o| o.options_type.as_deref())
786 });
787
788 let (mut setup_lines, args_str) = build_args_and_setup(
789 &fixture.input,
790 args,
791 class_name,
792 enum_fields,
793 &fixture.id,
794 options_via,
795 call_options_type,
796 );
797
798 let skip_test = call_config.skip_languages.iter().any(|l| l == "php");
800 if skip_test {
801 let rendered = crate::template_env::render(
802 "php/test_method.jinja",
803 minijinja::context! {
804 method_name => method_name,
805 description => description,
806 client_factory => String::new(),
807 setup_lines => Vec::<String>::new(),
808 expects_error => false,
809 skip_test => true,
810 has_usable_assertions => false,
811 call_expr => String::new(),
812 result_var => result_var,
813 assertions_body => String::new(),
814 },
815 );
816 out.push_str(&rendered);
817 return;
818 }
819
820 let mut options_already_created = !args_str.is_empty() && args_str == "$options";
822 if let Some(visitor_spec) = &fixture.visitor {
823 build_php_visitor(&mut setup_lines, visitor_spec);
824 if !options_already_created {
825 setup_lines.push("$builder = \\HtmlToMarkdown\\ConversionOptions::builder();".to_string());
826 setup_lines.push("$options = $builder->visitor($visitor)->build();".to_string());
827 options_already_created = true;
828 }
829 }
830
831 let final_args = if options_already_created {
832 if args_str.is_empty() || args_str == "$options" {
833 "$options".to_string()
834 } else {
835 format!("{args_str}, $options")
836 }
837 } else {
838 args_str
839 };
840
841 let call_expr = if php_client_factory.is_some() {
842 format!("$client->{function_name}({final_args})")
843 } else {
844 format!("{class_name}::{function_name}({final_args})")
845 };
846
847 let client_factory = if let Some(factory) = php_client_factory {
848 format!("$client = \\{namespace}\\{class_name}::{factory}('test-key');")
849 } else {
850 String::new()
851 };
852
853 let has_usable_assertions = fixture.assertions.iter().any(|a| {
855 if a.assertion_type == "error" || a.assertion_type == "not_error" {
856 return false;
857 }
858 match &a.field {
859 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
860 _ => true,
861 }
862 });
863
864 let mut assertions_body = String::new();
866 for assertion in &fixture.assertions {
867 render_assertion(
868 &mut assertions_body,
869 assertion,
870 result_var,
871 field_resolver,
872 result_is_simple,
873 call_config.result_is_array,
874 );
875 }
876
877 let rendered = crate::template_env::render(
878 "php/test_method.jinja",
879 minijinja::context! {
880 method_name => method_name,
881 description => description,
882 client_factory => client_factory,
883 setup_lines => setup_lines,
884 expects_error => expects_error,
885 skip_test => fixture.assertions.is_empty(),
886 has_usable_assertions => has_usable_assertions,
887 call_expr => call_expr,
888 result_var => result_var,
889 assertions_body => assertions_body,
890 },
891 );
892 out.push_str(&rendered);
893}
894
895fn emit_php_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
908 if let Some(items) = arr.as_array() {
909 let item_strs: Vec<String> = items
910 .iter()
911 .filter_map(|item| {
912 if let Some(obj) = item.as_object() {
913 match elem_type {
914 "BatchBytesItem" => {
915 let content = obj.get("content").and_then(|v| v.as_array());
916 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
917 let content_code = if let Some(arr) = content {
918 let bytes: Vec<String> = arr
919 .iter()
920 .filter_map(|v| v.as_u64())
921 .map(|n| format!("\\x{:02x}", n))
922 .collect();
923 format!("\"{}\"", bytes.join(""))
924 } else {
925 "\"\"".to_string()
926 };
927 Some(format!(
928 "new {}(content: {}, mimeType: \"{}\")",
929 elem_type, content_code, mime_type
930 ))
931 }
932 "BatchFileItem" => {
933 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
934 Some(format!("new {}(path: \"{}\")", elem_type, path))
935 }
936 _ => None,
937 }
938 } else {
939 None
940 }
941 })
942 .collect();
943 format!("[{}]", item_strs.join(", "))
944 } else {
945 "[]".to_string()
946 }
947}
948
949fn build_args_and_setup(
950 input: &serde_json::Value,
951 args: &[crate::config::ArgMapping],
952 class_name: &str,
953 _enum_fields: &HashMap<String, String>,
954 fixture_id: &str,
955 options_via: &str,
956 options_type: Option<&str>,
957) -> (Vec<String>, String) {
958 if args.is_empty() {
959 let is_empty_input = match input {
962 serde_json::Value::Null => true,
963 serde_json::Value::Object(m) => m.is_empty(),
964 _ => false,
965 };
966 if is_empty_input {
967 return (Vec::new(), String::new());
968 }
969 return (Vec::new(), json_to_php(input));
970 }
971
972 let mut setup_lines: Vec<String> = Vec::new();
973 let mut parts: Vec<String> = Vec::new();
974
975 let arg_has_emission = |arg: &crate::config::ArgMapping| -> bool {
980 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
981 let val = input.get(field);
982 match val {
983 None | Some(serde_json::Value::Null) => !arg.optional,
984 Some(_) => true,
985 }
986 };
987 let any_later_has_emission = |from_idx: usize| -> bool { args[from_idx..].iter().any(arg_has_emission) };
988
989 for (idx, arg) in args.iter().enumerate() {
990 if arg.arg_type == "mock_url" {
991 setup_lines.push(format!(
992 "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
993 arg.name,
994 ));
995 parts.push(format!("${}", arg.name));
996 continue;
997 }
998
999 if arg.arg_type == "handle" {
1000 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1002 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1003 let config_value = input.get(field).unwrap_or(&serde_json::Value::Null);
1004 if config_value.is_null()
1005 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1006 {
1007 setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
1008 } else {
1009 let name = &arg.name;
1010 let filtered_config = filter_empty_enum_strings(config_value);
1015 setup_lines.push(format!(
1016 "${name}_config = CrawlConfig::from_json(json_encode({}));",
1017 json_to_php(&filtered_config)
1018 ));
1019 setup_lines.push(format!(
1020 "${} = {class_name}::{constructor_name}(${name}_config);",
1021 arg.name,
1022 ));
1023 }
1024 parts.push(format!("${}", arg.name));
1025 continue;
1026 }
1027
1028 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1029 let val = input.get(field);
1030
1031 if arg.arg_type == "bytes" {
1035 match val {
1036 None | Some(serde_json::Value::Null) => {
1037 if arg.optional {
1038 parts.push("null".to_string());
1039 } else {
1040 parts.push("\"\"".to_string());
1041 }
1042 }
1043 Some(serde_json::Value::String(s)) => {
1044 let var_name = format!("{}Bytes", arg.name);
1045 setup_lines.push(format!(
1046 "${var_name} = file_get_contents(\"{path}\");\n if (${var_name} === false) {{ $this->fail(\"failed to read fixture: {path}\"); }}",
1047 path = s.replace('"', "\\\"")
1048 ));
1049 parts.push(format!("${var_name}"));
1050 }
1051 Some(serde_json::Value::Array(arr)) => {
1052 let bytes: String = arr
1053 .iter()
1054 .filter_map(|v| v.as_u64())
1055 .map(|n| format!("\\x{:02x}", n))
1056 .collect();
1057 parts.push(format!("\"{bytes}\""));
1058 }
1059 Some(other) => {
1060 parts.push(json_to_php(other));
1061 }
1062 }
1063 continue;
1064 }
1065
1066 match val {
1067 None | Some(serde_json::Value::Null) if arg.arg_type == "json_object" && arg.name == "config" => {
1068 let type_name = if arg.name == "config" {
1074 "ExtractionConfig".to_string()
1075 } else {
1076 format!("{}Config", arg.name.to_upper_camel_case())
1077 };
1078 parts.push(format!("{type_name}::from_json('{{}}')"));
1079 continue;
1080 }
1081 None | Some(serde_json::Value::Null) if arg.optional => {
1082 if any_later_has_emission(idx + 1) {
1087 parts.push("null".to_string());
1088 }
1089 continue;
1090 }
1091 None | Some(serde_json::Value::Null) => {
1092 let default_val = match arg.arg_type.as_str() {
1094 "string" => "\"\"".to_string(),
1095 "int" | "integer" => "0".to_string(),
1096 "float" | "number" => "0.0".to_string(),
1097 "bool" | "boolean" => "false".to_string(),
1098 "json_object" if options_via == "json" => "null".to_string(),
1099 _ => "null".to_string(),
1100 };
1101 parts.push(default_val);
1102 }
1103 Some(v) => {
1104 if arg.arg_type == "json_object" && !v.is_null() {
1105 if let Some(elem_type) = &arg.element_type {
1107 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1108 parts.push(emit_php_batch_item_array(v, elem_type));
1109 continue;
1110 }
1111 if v.is_array() && is_php_reserved_type(elem_type) {
1115 parts.push(json_to_php(v));
1116 continue;
1117 }
1118 }
1119 match options_via {
1120 "json" => {
1121 let filtered_v = filter_empty_enum_strings(v);
1124
1125 if let serde_json::Value::Object(obj) = &filtered_v {
1127 if obj.is_empty() {
1128 parts.push("null".to_string());
1129 continue;
1130 }
1131 }
1132
1133 parts.push(format!("json_encode({})", json_to_php_camel_keys(&filtered_v)));
1134 continue;
1135 }
1136 _ => {
1137 if let Some(type_name) = options_type {
1138 let filtered_v = filter_empty_enum_strings(v);
1143
1144 if let serde_json::Value::Object(obj) = &filtered_v {
1147 if obj.is_empty() {
1148 let arg_var = format!("${}", arg.name);
1149 setup_lines.push(format!("{arg_var} = {type_name}::from_json('{{}}');"));
1150 parts.push(arg_var);
1151 continue;
1152 }
1153 }
1154
1155 let arg_var = format!("${}", arg.name);
1156 setup_lines.push(format!(
1160 "{arg_var} = {type_name}::from_json(json_encode({}));",
1161 json_to_php(&filtered_v)
1162 ));
1163 parts.push(arg_var);
1164 continue;
1165 }
1166 if let Some(obj) = v.as_object() {
1170 setup_lines.push("$builder = $this->createDefaultOptionsBuilder();".to_string());
1171 for (k, vv) in obj {
1172 let snake_key = k.to_snake_case();
1173 if snake_key == "preprocessing" {
1174 if let Some(prep_obj) = vv.as_object() {
1175 let enabled =
1176 prep_obj.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
1177 let preset =
1178 prep_obj.get("preset").and_then(|v| v.as_str()).unwrap_or("Minimal");
1179 let remove_navigation = prep_obj
1180 .get("remove_navigation")
1181 .and_then(|v| v.as_bool())
1182 .unwrap_or(true);
1183 let remove_forms =
1184 prep_obj.get("remove_forms").and_then(|v| v.as_bool()).unwrap_or(true);
1185 setup_lines.push(format!(
1186 "$preprocessing = $this->createPreprocessingOptions({}, {}, {}, {});",
1187 if enabled { "true" } else { "false" },
1188 json_to_php(&serde_json::Value::String(preset.to_string())),
1189 if remove_navigation { "true" } else { "false" },
1190 if remove_forms { "true" } else { "false" }
1191 ));
1192 setup_lines.push(
1193 "$builder = $builder->preprocessing($preprocessing);".to_string(),
1194 );
1195 }
1196 }
1197 }
1198 setup_lines.push("$options = $builder->build();".to_string());
1199 parts.push("$options".to_string());
1200 continue;
1201 }
1202 }
1203 }
1204 }
1205 parts.push(json_to_php(v));
1206 }
1207 }
1208 }
1209
1210 (setup_lines, parts.join(", "))
1211}
1212
1213fn render_assertion(
1214 out: &mut String,
1215 assertion: &Assertion,
1216 result_var: &str,
1217 field_resolver: &FieldResolver,
1218 result_is_simple: bool,
1219 result_is_array: bool,
1220) {
1221 if let Some(f) = &assertion.field {
1224 match f.as_str() {
1225 "chunks_have_content" => {
1226 let pred = format!(
1227 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->content), true)"
1228 );
1229 out.push_str(&crate::template_env::render(
1230 "php/synthetic_assertion.jinja",
1231 minijinja::context! {
1232 assertion_kind => "chunks_content",
1233 assertion_type => assertion.assertion_type.as_str(),
1234 pred => pred,
1235 field_name => f,
1236 },
1237 ));
1238 return;
1239 }
1240 "chunks_have_embeddings" => {
1241 let pred = format!(
1242 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->embedding), true)"
1243 );
1244 out.push_str(&crate::template_env::render(
1245 "php/synthetic_assertion.jinja",
1246 minijinja::context! {
1247 assertion_kind => "chunks_embeddings",
1248 assertion_type => assertion.assertion_type.as_str(),
1249 pred => pred,
1250 field_name => f,
1251 },
1252 ));
1253 return;
1254 }
1255 "embeddings" => {
1259 let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1260 out.push_str(&crate::template_env::render(
1261 "php/synthetic_assertion.jinja",
1262 minijinja::context! {
1263 assertion_kind => "embeddings",
1264 assertion_type => assertion.assertion_type.as_str(),
1265 php_val => php_val,
1266 result_var => result_var,
1267 },
1268 ));
1269 return;
1270 }
1271 "embedding_dimensions" => {
1272 let expr = format!("(empty(${result_var}) ? 0 : count(${result_var}[0]))");
1273 let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1274 out.push_str(&crate::template_env::render(
1275 "php/synthetic_assertion.jinja",
1276 minijinja::context! {
1277 assertion_kind => "embedding_dimensions",
1278 assertion_type => assertion.assertion_type.as_str(),
1279 expr => expr,
1280 php_val => php_val,
1281 },
1282 ));
1283 return;
1284 }
1285 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1286 let pred = match f.as_str() {
1287 "embeddings_valid" => {
1288 format!("array_reduce(${result_var}, fn($carry, $e) => $carry && count($e) > 0, true)")
1289 }
1290 "embeddings_finite" => {
1291 format!(
1292 "array_reduce(${result_var}, fn($carry, $e) => $carry && array_reduce($e, fn($c, $v) => $c && is_finite($v), true), true)"
1293 )
1294 }
1295 "embeddings_non_zero" => {
1296 format!(
1297 "array_reduce(${result_var}, fn($carry, $e) => $carry && count(array_filter($e, fn($v) => $v !== 0.0)) > 0, true)"
1298 )
1299 }
1300 "embeddings_normalized" => {
1301 format!(
1302 "array_reduce(${result_var}, fn($carry, $e) => $carry && abs(array_sum(array_map(fn($v) => $v * $v, $e)) - 1.0) < 1e-3, true)"
1303 )
1304 }
1305 _ => unreachable!(),
1306 };
1307 let assertion_kind = format!("embeddings_{}", f.strip_prefix("embeddings_").unwrap_or(f));
1308 out.push_str(&crate::template_env::render(
1309 "php/synthetic_assertion.jinja",
1310 minijinja::context! {
1311 assertion_kind => assertion_kind,
1312 assertion_type => assertion.assertion_type.as_str(),
1313 pred => pred,
1314 field_name => f,
1315 },
1316 ));
1317 return;
1318 }
1319 "keywords" | "keywords_count" => {
1322 out.push_str(&crate::template_env::render(
1323 "php/synthetic_assertion.jinja",
1324 minijinja::context! {
1325 assertion_kind => "keywords",
1326 field_name => f,
1327 },
1328 ));
1329 return;
1330 }
1331 _ => {}
1332 }
1333 }
1334
1335 if let Some(f) = &assertion.field {
1337 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1338 out.push_str(&crate::template_env::render(
1339 "php/synthetic_assertion.jinja",
1340 minijinja::context! {
1341 assertion_kind => "skipped",
1342 field_name => f,
1343 },
1344 ));
1345 return;
1346 }
1347 }
1348
1349 if result_is_simple {
1352 if let Some(f) = &assertion.field {
1353 let f_lower = f.to_lowercase();
1354 if !f.is_empty()
1355 && f_lower != "content"
1356 && (f_lower.starts_with("metadata")
1357 || f_lower.starts_with("document")
1358 || f_lower.starts_with("structure"))
1359 {
1360 out.push_str(&crate::template_env::render(
1361 "php/synthetic_assertion.jinja",
1362 minijinja::context! {
1363 assertion_kind => "result_is_simple",
1364 field_name => f,
1365 },
1366 ));
1367 return;
1368 }
1369 }
1370 }
1371
1372 let field_expr = match &assertion.field {
1373 Some(f) if !f.is_empty() => field_resolver.accessor(f, "php", &format!("${result_var}")),
1374 _ if result_is_simple => {
1375 field_resolver.accessor("content", "php", &format!("${result_var}"))
1377 }
1378 _ => format!("${result_var}"),
1379 };
1380
1381 let field_is_array = assertion.field.as_ref().map_or(result_is_array, |f| {
1384 if f.is_empty() {
1385 result_is_array
1386 } else {
1387 field_resolver.is_array(f)
1388 }
1389 });
1390
1391 let trimmed_field_expr_for = |expected: &serde_json::Value| -> String {
1395 if expected.is_string() {
1396 format!("trim({})", field_expr)
1397 } else {
1398 field_expr.clone()
1399 }
1400 };
1401
1402 let assertion_type = assertion.assertion_type.as_str();
1404 let has_php_val = assertion.value.is_some();
1405 let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1406 let trimmed_field_expr = trimmed_field_expr_for(assertion.value.as_ref().unwrap_or(&serde_json::Value::Null));
1407 let is_string_val = assertion.value.as_ref().is_some_and(|v| v.is_string());
1408 let values_php: Vec<String> = assertion
1409 .values
1410 .as_ref()
1411 .map_or(Vec::new(), |vals| vals.iter().map(json_to_php).collect());
1412 let contains_any_checks: Vec<String> = assertion
1413 .values
1414 .as_ref()
1415 .map_or(Vec::new(), |vals| vals.iter().map(json_to_php).collect());
1416 let n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1417
1418 let call_expr = if let Some(method_name) = &assertion.method {
1420 build_php_method_call(result_var, method_name, assertion.args.as_ref())
1421 } else {
1422 String::new()
1423 };
1424 let check = assertion.check.as_deref().unwrap_or("is_true");
1425 let has_php_check_val = matches!(assertion.assertion_type.as_str(), "method_result") && assertion.value.is_some();
1426 let php_check_val = if matches!(assertion.assertion_type.as_str(), "method_result") {
1427 assertion.value.as_ref().map(json_to_php).unwrap_or_default()
1428 } else {
1429 String::new()
1430 };
1431 let check_n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1432 let is_bool_val = assertion.value.as_ref().is_some_and(|v| v.is_boolean());
1433 let bool_is_true = assertion.value.as_ref().and_then(|v| v.as_bool()).unwrap_or(false);
1434
1435 if matches!(assertion_type, "not_error" | "error") {
1437 if assertion_type == "not_error" {
1438 }
1440 return;
1442 }
1443
1444 let rendered = crate::template_env::render(
1445 "php/assertion.jinja",
1446 minijinja::context! {
1447 assertion_type => assertion_type,
1448 field_expr => field_expr,
1449 php_val => php_val,
1450 has_php_val => has_php_val,
1451 trimmed_field_expr => trimmed_field_expr,
1452 is_string_val => is_string_val,
1453 field_is_array => field_is_array,
1454 values_php => values_php,
1455 contains_any_checks => contains_any_checks,
1456 n => n,
1457 call_expr => call_expr,
1458 check => check,
1459 php_check_val => php_check_val,
1460 has_php_check_val => has_php_check_val,
1461 check_n => check_n,
1462 is_bool_val => is_bool_val,
1463 bool_is_true => bool_is_true,
1464 },
1465 );
1466 let _ = write!(out, " {}", rendered);
1467}
1468
1469fn build_php_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1476 let extra_args = if let Some(args_val) = args {
1477 args_val
1478 .as_object()
1479 .map(|obj| {
1480 obj.values()
1481 .map(|v| match v {
1482 serde_json::Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
1483 serde_json::Value::Bool(true) => "true".to_string(),
1484 serde_json::Value::Bool(false) => "false".to_string(),
1485 serde_json::Value::Number(n) => n.to_string(),
1486 serde_json::Value::Null => "null".to_string(),
1487 other => format!("\"{}\"", other.to_string().replace('\\', "\\\\").replace('"', "\\\"")),
1488 })
1489 .collect::<Vec<_>>()
1490 .join(", ")
1491 })
1492 .unwrap_or_default()
1493 } else {
1494 String::new()
1495 };
1496
1497 if extra_args.is_empty() {
1498 format!("${result_var}->{method_name}()")
1499 } else {
1500 format!("${result_var}->{method_name}({extra_args})")
1501 }
1502}
1503
1504fn filter_empty_enum_strings(value: &serde_json::Value) -> serde_json::Value {
1508 match value {
1509 serde_json::Value::Object(map) => {
1510 let filtered: serde_json::Map<String, serde_json::Value> = map
1511 .iter()
1512 .filter_map(|(k, v)| {
1513 if let serde_json::Value::String(s) = v {
1515 if s.is_empty() {
1516 return None;
1517 }
1518 }
1519 Some((k.clone(), filter_empty_enum_strings(v)))
1521 })
1522 .collect();
1523 serde_json::Value::Object(filtered)
1524 }
1525 serde_json::Value::Array(arr) => {
1526 let filtered: Vec<serde_json::Value> = arr.iter().map(filter_empty_enum_strings).collect();
1527 serde_json::Value::Array(filtered)
1528 }
1529 other => other.clone(),
1530 }
1531}
1532
1533fn json_to_php(value: &serde_json::Value) -> String {
1535 match value {
1536 serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
1537 serde_json::Value::Bool(true) => "true".to_string(),
1538 serde_json::Value::Bool(false) => "false".to_string(),
1539 serde_json::Value::Number(n) => n.to_string(),
1540 serde_json::Value::Null => "null".to_string(),
1541 serde_json::Value::Array(arr) => {
1542 let items: Vec<String> = arr.iter().map(json_to_php).collect();
1543 format!("[{}]", items.join(", "))
1544 }
1545 serde_json::Value::Object(map) => {
1546 let items: Vec<String> = map
1547 .iter()
1548 .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
1549 .collect();
1550 format!("[{}]", items.join(", "))
1551 }
1552 }
1553}
1554
1555fn json_to_php_camel_keys(value: &serde_json::Value) -> String {
1560 match value {
1561 serde_json::Value::Object(map) => {
1562 let items: Vec<String> = map
1563 .iter()
1564 .map(|(k, v)| {
1565 let camel_key = k.to_lower_camel_case();
1566 format!("\"{}\" => {}", escape_php(&camel_key), json_to_php_camel_keys(v))
1567 })
1568 .collect();
1569 format!("[{}]", items.join(", "))
1570 }
1571 serde_json::Value::Array(arr) => {
1572 let items: Vec<String> = arr.iter().map(json_to_php_camel_keys).collect();
1573 format!("[{}]", items.join(", "))
1574 }
1575 _ => json_to_php(value),
1576 }
1577}
1578
1579fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
1585 setup_lines.push("$visitor = new class {".to_string());
1586 for (method_name, action) in &visitor_spec.callbacks {
1587 emit_php_visitor_method(setup_lines, method_name, action);
1588 }
1589 setup_lines.push("};".to_string());
1590}
1591
1592fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1594 let params = match method_name {
1595 "visit_link" => "$ctx, $href, $text, $title",
1596 "visit_image" => "$ctx, $src, $alt, $title",
1597 "visit_heading" => "$ctx, $level, $text, $id",
1598 "visit_code_block" => "$ctx, $lang, $code",
1599 "visit_code_inline"
1600 | "visit_strong"
1601 | "visit_emphasis"
1602 | "visit_strikethrough"
1603 | "visit_underline"
1604 | "visit_subscript"
1605 | "visit_superscript"
1606 | "visit_mark"
1607 | "visit_button"
1608 | "visit_summary"
1609 | "visit_figcaption"
1610 | "visit_definition_term"
1611 | "visit_definition_description" => "$ctx, $text",
1612 "visit_text" => "$ctx, $text",
1613 "visit_list_item" => "$ctx, $ordered, $marker, $text",
1614 "visit_blockquote" => "$ctx, $content, $depth",
1615 "visit_table_row" => "$ctx, $cells, $isHeader",
1616 "visit_custom_element" => "$ctx, $tagName, $html",
1617 "visit_form" => "$ctx, $actionUrl, $method",
1618 "visit_input" => "$ctx, $input_type, $name, $value",
1619 "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
1620 "visit_details" => "$ctx, $isOpen",
1621 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "$ctx, $output",
1622 "visit_list_start" => "$ctx, $ordered",
1623 "visit_list_end" => "$ctx, $ordered, $output",
1624 _ => "$ctx",
1625 };
1626
1627 let (action_type, action_value) = match action {
1628 CallbackAction::Skip => ("skip", String::new()),
1629 CallbackAction::Continue => ("continue", String::new()),
1630 CallbackAction::PreserveHtml => ("preserve_html", String::new()),
1631 CallbackAction::Custom { output } => ("custom", escape_php(output)),
1632 CallbackAction::CustomTemplate { template } => ("custom_template", escape_php(template)),
1633 };
1634
1635 let rendered = crate::template_env::render(
1636 "php/visitor_method.jinja",
1637 minijinja::context! {
1638 method_name => method_name,
1639 params => params,
1640 action_type => action_type,
1641 action_value => action_value,
1642 },
1643 );
1644 for line in rendered.lines() {
1645 setup_lines.push(line.to_string());
1646 }
1647}
1648
1649fn is_php_reserved_type(name: &str) -> bool {
1651 matches!(
1652 name.to_ascii_lowercase().as_str(),
1653 "string"
1654 | "int"
1655 | "integer"
1656 | "float"
1657 | "double"
1658 | "bool"
1659 | "boolean"
1660 | "array"
1661 | "object"
1662 | "null"
1663 | "void"
1664 | "callable"
1665 | "iterable"
1666 | "never"
1667 | "self"
1668 | "parent"
1669 | "static"
1670 | "true"
1671 | "false"
1672 | "mixed"
1673 )
1674}