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