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(&pkg_path, has_http_fixtures, has_file_fixtures),
151 generated_header: true,
152 });
153
154 files.push(GeneratedFile {
156 path: output_base.join("run_tests.php"),
157 content: render_run_tests_php(&extension_name, config.php_cargo_crate_name()),
158 generated_header: true,
159 });
160
161 let tests_base = output_base.join("tests");
163 let field_resolver = FieldResolver::new(
164 &e2e_config.fields,
165 &e2e_config.fields_optional,
166 &e2e_config.result_fields,
167 &e2e_config.fields_array,
168 &std::collections::HashSet::new(),
169 );
170
171 for group in groups {
172 let active: Vec<&Fixture> = group
173 .fixtures
174 .iter()
175 .filter(|f| super::should_include_fixture(f, lang, e2e_config))
176 .collect();
177
178 if active.is_empty() {
179 continue;
180 }
181
182 let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
183 let filename = format!("{test_class}.php");
184 let content = render_test_file(
185 &group.category,
186 &active,
187 e2e_config,
188 lang,
189 &namespace,
190 &class_name,
191 &test_class,
192 &field_resolver,
193 enum_fields,
194 result_is_simple,
195 php_client_factory,
196 options_via,
197 );
198 files.push(GeneratedFile {
199 path: tests_base.join(filename),
200 content,
201 generated_header: true,
202 });
203 }
204
205 Ok(files)
206 }
207
208 fn language_name(&self) -> &'static str {
209 "php"
210 }
211}
212
213fn render_composer_json(
218 e2e_pkg_name: &str,
219 e2e_autoload_ns: &str,
220 pkg_name: &str,
221 pkg_path: &str,
222 pkg_version: &str,
223 dep_mode: crate::config::DependencyMode,
224) -> String {
225 let (require_section, autoload_section) = match dep_mode {
226 crate::config::DependencyMode::Registry => {
227 let require = format!(
228 r#" "require": {{
229 "{pkg_name}": "{pkg_version}"
230 }},
231 "require-dev": {{
232 "phpunit/phpunit": "{phpunit}",
233 "guzzlehttp/guzzle": "{guzzle}"
234 }},"#,
235 phpunit = tv::packagist::PHPUNIT,
236 guzzle = tv::packagist::GUZZLE,
237 );
238 (require, String::new())
239 }
240 crate::config::DependencyMode::Local => {
241 let require = format!(
242 r#" "require-dev": {{
243 "phpunit/phpunit": "{phpunit}",
244 "guzzlehttp/guzzle": "{guzzle}"
245 }},"#,
246 phpunit = tv::packagist::PHPUNIT,
247 guzzle = tv::packagist::GUZZLE,
248 );
249 let pkg_namespace = pkg_name
252 .split('/')
253 .nth(1)
254 .unwrap_or(pkg_name)
255 .split('-')
256 .map(heck::ToUpperCamelCase::to_upper_camel_case)
257 .collect::<Vec<_>>()
258 .join("\\");
259 let autoload = format!(
260 r#"
261 "autoload": {{
262 "psr-4": {{
263 "{}\\": "{}/src/"
264 }}
265 }},"#,
266 pkg_namespace.replace('\\', "\\\\"),
267 pkg_path
268 );
269 (require, autoload)
270 }
271 };
272
273 crate::template_env::render(
274 "php/composer.json.jinja",
275 minijinja::context! {
276 e2e_pkg_name => e2e_pkg_name,
277 e2e_autoload_ns => e2e_autoload_ns,
278 require_section => require_section,
279 autoload_section => autoload_section,
280 },
281 )
282}
283
284fn render_phpunit_xml() -> String {
285 crate::template_env::render("php/phpunit.xml.jinja", minijinja::context! {})
286}
287
288fn render_bootstrap(pkg_path: &str, has_http_fixtures: bool, has_file_fixtures: bool) -> String {
289 let header = hash::header(CommentStyle::DoubleSlash);
290 crate::template_env::render(
291 "php/bootstrap.php.jinja",
292 minijinja::context! {
293 header => header,
294 pkg_path => pkg_path,
295 has_http_fixtures => has_http_fixtures,
296 has_file_fixtures => has_file_fixtures,
297 },
298 )
299}
300
301fn render_run_tests_php(extension_name: &str, cargo_crate_name: Option<&str>) -> String {
302 let header = hash::header(CommentStyle::DoubleSlash);
303 let ext_lib_name = if let Some(crate_name) = cargo_crate_name {
304 format!("lib{}", crate_name.replace('-', "_"))
307 } else {
308 format!("lib{extension_name}_php")
309 };
310 format!(
311 r#"#!/usr/bin/env php
312<?php
313{header}
314declare(strict_types=1);
315
316// Determine platform-specific extension suffix.
317$extSuffix = match (PHP_OS_FAMILY) {{
318 'Darwin' => '.dylib',
319 default => '.so',
320}};
321$extPath = __DIR__ . '/../../target/release/{ext_lib_name}' . $extSuffix;
322
323// If the locally-built extension exists and we have not already restarted with it,
324// re-exec PHP with no system ini (-n) to avoid conflicts with any system-installed
325// version of the extension, then load the local build explicitly.
326if (file_exists($extPath) && !getenv('ALEF_PHP_LOCAL_EXT_LOADED')) {{
327 putenv('ALEF_PHP_LOCAL_EXT_LOADED=1');
328 $php = PHP_BINARY;
329 $phpunitPath = __DIR__ . '/vendor/bin/phpunit';
330
331 $cmd = array_merge(
332 [$php, '-n', '-d', 'extension=' . $extPath],
333 [$phpunitPath],
334 array_slice($GLOBALS['argv'], 1)
335 );
336
337 passthru(implode(' ', array_map('escapeshellarg', $cmd)), $exitCode);
338 exit($exitCode);
339}}
340
341// Extension is now loaded (via the restart above with -n flag).
342// Invoke PHPUnit normally.
343$phpunitPath = __DIR__ . '/vendor/bin/phpunit';
344if (!file_exists($phpunitPath)) {{
345 echo "PHPUnit not found at $phpunitPath. Run 'composer install' first.\n";
346 exit(1);
347}}
348
349require $phpunitPath;
350"#
351 )
352}
353
354#[allow(clippy::too_many_arguments)]
355fn render_test_file(
356 category: &str,
357 fixtures: &[&Fixture],
358 e2e_config: &E2eConfig,
359 lang: &str,
360 namespace: &str,
361 class_name: &str,
362 test_class: &str,
363 field_resolver: &FieldResolver,
364 enum_fields: &HashMap<String, String>,
365 result_is_simple: bool,
366 php_client_factory: Option<&str>,
367 options_via: &str,
368) -> String {
369 let header = hash::header(CommentStyle::DoubleSlash);
370
371 let needs_crawl_config_import = fixtures.iter().any(|f| {
373 let call = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
374 call.args.iter().filter(|a| a.arg_type == "handle").any(|a| {
375 let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
376 !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
377 })
378 });
379
380 let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
382
383 let mut options_type_imports: Vec<String> = fixtures
385 .iter()
386 .flat_map(|f| {
387 let call = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
388 let php_override = call.overrides.get(lang);
389 let opt_type = php_override.and_then(|o| o.options_type.as_deref()).or_else(|| {
390 e2e_config
391 .call
392 .overrides
393 .get(lang)
394 .and_then(|o| o.options_type.as_deref())
395 });
396 let element_types: Vec<String> = call
397 .args
398 .iter()
399 .filter_map(|a| a.element_type.as_ref().map(|t| t.to_string()))
400 .filter(|t| !is_php_reserved_type(t))
401 .collect();
402 opt_type.map(|t| t.to_string()).into_iter().chain(element_types)
403 })
404 .collect::<std::collections::HashSet<_>>()
405 .into_iter()
406 .collect();
407 options_type_imports.sort();
408
409 let mut imports_use: Vec<String> = Vec::new();
411 if needs_crawl_config_import {
412 imports_use.push(format!("use {namespace}\\CrawlConfig;"));
413 }
414 for type_name in &options_type_imports {
415 if type_name != class_name {
416 imports_use.push(format!("use {namespace}\\{type_name};"));
417 }
418 }
419
420 let mut fixtures_body = String::new();
422 for (i, fixture) in fixtures.iter().enumerate() {
423 if fixture.is_http_test() {
424 render_http_test_method(&mut fixtures_body, fixture, fixture.http.as_ref().unwrap());
425 } else {
426 render_test_method(
427 &mut fixtures_body,
428 fixture,
429 e2e_config,
430 lang,
431 namespace,
432 class_name,
433 field_resolver,
434 enum_fields,
435 result_is_simple,
436 php_client_factory,
437 options_via,
438 );
439 }
440 if i + 1 < fixtures.len() {
441 fixtures_body.push('\n');
442 }
443 }
444
445 crate::template_env::render(
446 "php/test_file.jinja",
447 minijinja::context! {
448 header => header,
449 namespace => namespace,
450 class_name => class_name,
451 test_class => test_class,
452 category => category,
453 imports_use => imports_use,
454 has_http_tests => has_http_tests,
455 fixtures_body => fixtures_body,
456 },
457 )
458}
459
460struct PhpTestClientRenderer;
468
469impl client::TestClientRenderer for PhpTestClientRenderer {
470 fn language_name(&self) -> &'static str {
471 "php"
472 }
473
474 fn sanitize_test_name(&self, id: &str) -> String {
476 sanitize_filename(id)
477 }
478
479 fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
485 let escaped_reason = skip_reason.map(escape_php);
486 let rendered = crate::template_env::render(
487 "php/http_test_open.jinja",
488 minijinja::context! {
489 fn_name => fn_name,
490 description => description,
491 skip_reason => escaped_reason,
492 },
493 );
494 out.push_str(&rendered);
495 }
496
497 fn render_test_close(&self, out: &mut String) {
499 let rendered = crate::template_env::render("php/http_test_close.jinja", minijinja::context! {});
500 out.push_str(&rendered);
501 }
502
503 fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
508 let method = ctx.method.to_uppercase();
509
510 let mut opts: Vec<String> = Vec::new();
512
513 if let Some(body) = ctx.body {
514 let php_body = json_to_php(body);
515 opts.push(format!("'json' => {php_body}"));
516 }
517
518 let mut header_pairs: Vec<String> = Vec::new();
520 if let Some(ct) = ctx.content_type {
521 if !ctx.headers.keys().any(|k| k.to_lowercase() == "content-type") {
523 header_pairs.push(format!("\"Content-Type\" => \"{}\"", escape_php(ct)));
524 }
525 }
526 for (k, v) in ctx.headers {
527 header_pairs.push(format!("\"{}\" => \"{}\"", escape_php(k), escape_php(v)));
528 }
529 if !header_pairs.is_empty() {
530 opts.push(format!("'headers' => [{}]", header_pairs.join(", ")));
531 }
532
533 if !ctx.cookies.is_empty() {
534 let cookie_str = ctx
535 .cookies
536 .iter()
537 .map(|(k, v)| format!("{}={}", k, v))
538 .collect::<Vec<_>>()
539 .join("; ");
540 opts.push(format!("'headers' => ['Cookie' => \"{}\"]", escape_php(&cookie_str)));
541 }
542
543 if !ctx.query_params.is_empty() {
544 let pairs: Vec<String> = ctx
545 .query_params
546 .iter()
547 .map(|(k, v)| {
548 let val_str = match v {
549 serde_json::Value::String(s) => s.clone(),
550 other => other.to_string(),
551 };
552 format!("\"{}\" => \"{}\"", escape_php(k), escape_php(&val_str))
553 })
554 .collect();
555 opts.push(format!("'query' => [{}]", pairs.join(", ")));
556 }
557
558 let path_lit = format!("\"{}\"", escape_php(ctx.path));
559
560 let rendered = crate::template_env::render(
561 "php/http_request.jinja",
562 minijinja::context! {
563 method => method,
564 path => path_lit,
565 opts => opts,
566 response_var => ctx.response_var,
567 },
568 );
569 out.push_str(&rendered);
570 }
571
572 fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
574 let rendered = crate::template_env::render(
575 "php/http_assertions.jinja",
576 minijinja::context! {
577 response_var => "",
578 status_code => status,
579 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
580 body_assertion => String::new(),
581 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
582 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
583 },
584 );
585 out.push_str(&rendered);
586 }
587
588 fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
593 let header_key = name.to_lowercase();
594 let header_key_lit = format!("\"{}\"", escape_php(&header_key));
595 let assertion_code = match expected {
596 "<<present>>" => {
597 format!("$this->assertTrue($response->hasHeader({header_key_lit}));")
598 }
599 "<<absent>>" => {
600 format!("$this->assertFalse($response->hasHeader({header_key_lit}));")
601 }
602 "<<uuid>>" => {
603 format!(
604 "$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}));"
605 )
606 }
607 literal => {
608 let val_lit = format!("\"{}\"", escape_php(literal));
609 format!("$this->assertEquals({val_lit}, $response->getHeaderLine({header_key_lit}));")
610 }
611 };
612
613 let mut headers = vec![std::collections::HashMap::new()];
614 headers[0].insert("assertion_code", assertion_code);
615
616 let rendered = crate::template_env::render(
617 "php/http_assertions.jinja",
618 minijinja::context! {
619 response_var => "",
620 status_code => 0u16,
621 headers => headers,
622 body_assertion => String::new(),
623 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
624 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
625 },
626 );
627 out.push_str(&rendered);
628 }
629
630 fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
636 let body_assertion = match expected {
637 serde_json::Value::String(s) if !s.is_empty() => {
638 let php_val = format!("\"{}\"", escape_php(s));
639 format!("$this->assertEquals({php_val}, (string) $response->getBody());")
640 }
641 _ => {
642 let php_val = json_to_php(expected);
643 format!(
644 "$body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);\n $this->assertEquals({php_val}, $body);"
645 )
646 }
647 };
648
649 let rendered = crate::template_env::render(
650 "php/http_assertions.jinja",
651 minijinja::context! {
652 response_var => "",
653 status_code => 0u16,
654 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
655 body_assertion => body_assertion,
656 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
657 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
658 },
659 );
660 out.push_str(&rendered);
661 }
662
663 fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
665 if let Some(obj) = expected.as_object() {
666 let mut partial_body: Vec<std::collections::HashMap<&str, String>> = Vec::new();
667 for (key, val) in obj {
668 let php_key = format!("\"{}\"", escape_php(key));
669 let php_val = json_to_php(val);
670 let assertion_code = format!("$this->assertEquals({php_val}, $body[{php_key}]);");
671 let mut entry = std::collections::HashMap::new();
672 entry.insert("assertion_code", assertion_code);
673 partial_body.push(entry);
674 }
675
676 let rendered = crate::template_env::render(
677 "php/http_assertions.jinja",
678 minijinja::context! {
679 response_var => "",
680 status_code => 0u16,
681 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
682 body_assertion => String::new(),
683 partial_body => partial_body,
684 validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
685 },
686 );
687 out.push_str(&rendered);
688 }
689 }
690
691 fn render_assert_validation_errors(
694 &self,
695 out: &mut String,
696 _response_var: &str,
697 errors: &[ValidationErrorExpectation],
698 ) {
699 let mut validation_errors: Vec<std::collections::HashMap<&str, String>> = Vec::new();
700 for err in errors {
701 let msg_lit = format!("\"{}\"", escape_php(&err.msg));
702 let assertion_code =
703 format!("$this->assertStringContainsString({msg_lit}, json_encode($body, JSON_UNESCAPED_SLASHES));");
704 let mut entry = std::collections::HashMap::new();
705 entry.insert("assertion_code", assertion_code);
706 validation_errors.push(entry);
707 }
708
709 let rendered = crate::template_env::render(
710 "php/http_assertions.jinja",
711 minijinja::context! {
712 response_var => "",
713 status_code => 0u16,
714 headers => Vec::<std::collections::HashMap<&str, String>>::new(),
715 body_assertion => String::new(),
716 partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
717 validation_errors => validation_errors,
718 },
719 );
720 out.push_str(&rendered);
721 }
722}
723
724fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
729 if http.expected_response.status_code == 101 {
733 let method_name = sanitize_filename(&fixture.id);
734 let description = &fixture.description;
735 out.push_str(&crate::template_env::render(
736 "php/http_test_skip_101.jinja",
737 minijinja::context! {
738 method_name => method_name,
739 description => description,
740 },
741 ));
742 return;
743 }
744
745 client::http_call::render_http_test(out, &PhpTestClientRenderer, fixture);
746}
747
748#[allow(clippy::too_many_arguments)]
753fn render_test_method(
754 out: &mut String,
755 fixture: &Fixture,
756 e2e_config: &E2eConfig,
757 lang: &str,
758 namespace: &str,
759 class_name: &str,
760 field_resolver: &FieldResolver,
761 enum_fields: &HashMap<String, String>,
762 result_is_simple: bool,
763 php_client_factory: Option<&str>,
764 options_via: &str,
765) {
766 let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
768 let call_overrides = call_config.overrides.get(lang);
769 let has_override = call_overrides.is_some_and(|o| o.function.is_some());
770 let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || result_is_simple;
774 let mut function_name = call_overrides
775 .and_then(|o| o.function.as_ref())
776 .cloned()
777 .unwrap_or_else(|| call_config.function.clone());
778 if !has_override && call_config.r#async && !function_name.ends_with("_async") {
781 function_name = format!("{function_name}_async");
782 }
783 if !has_override {
784 function_name = function_name.to_lower_camel_case();
785 }
786 let result_var = &call_config.result_var;
787 let args = &call_config.args;
788
789 let method_name = sanitize_filename(&fixture.id);
790 let description = &fixture.description;
791 let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
792
793 let call_options_type = call_overrides.and_then(|o| o.options_type.as_deref()).or_else(|| {
795 e2e_config
796 .call
797 .overrides
798 .get(lang)
799 .and_then(|o| o.options_type.as_deref())
800 });
801
802 let (mut setup_lines, args_str) = build_args_and_setup(
803 &fixture.input,
804 args,
805 class_name,
806 enum_fields,
807 &fixture.id,
808 options_via,
809 call_options_type,
810 );
811
812 let skip_test = call_config.skip_languages.iter().any(|l| l == "php");
814 if skip_test {
815 let rendered = crate::template_env::render(
816 "php/test_method.jinja",
817 minijinja::context! {
818 method_name => method_name,
819 description => description,
820 client_factory => String::new(),
821 setup_lines => Vec::<String>::new(),
822 expects_error => false,
823 skip_test => true,
824 has_usable_assertions => false,
825 call_expr => String::new(),
826 result_var => result_var,
827 assertions_body => String::new(),
828 },
829 );
830 out.push_str(&rendered);
831 return;
832 }
833
834 let mut options_already_created = !args_str.is_empty() && args_str == "$options";
836 if let Some(visitor_spec) = &fixture.visitor {
837 build_php_visitor(&mut setup_lines, visitor_spec);
838 if !options_already_created {
839 setup_lines.push("$builder = \\HtmlToMarkdown\\ConversionOptions::builder();".to_string());
840 setup_lines.push("$options = $builder->visitor($visitor)->build();".to_string());
841 options_already_created = true;
842 }
843 }
844
845 let final_args = if options_already_created {
846 if args_str.is_empty() || args_str == "$options" {
847 "$options".to_string()
848 } else {
849 format!("{args_str}, $options")
850 }
851 } else {
852 args_str
853 };
854
855 let call_expr = if php_client_factory.is_some() {
856 format!("$client->{function_name}({final_args})")
857 } else {
858 format!("{class_name}::{function_name}({final_args})")
859 };
860
861 let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
862 let api_key_var = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
863 let client_factory = if let Some(factory) = php_client_factory {
864 let fixture_id = &fixture.id;
865 if has_mock {
866 format!(
867 "$client = \\{namespace}\\{class_name}::{factory}('test-key', getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}');"
868 )
869 } else if let Some(var) = api_key_var {
870 format!(
871 "$apiKey = getenv('{var}');\n if (!$apiKey) {{ $this->markTestSkipped('{var} not set'); return; }}\n $client = \\{namespace}\\{class_name}::{factory}($apiKey);"
872 )
873 } else {
874 format!("$client = \\{namespace}\\{class_name}::{factory}('test-key');")
875 }
876 } else {
877 String::new()
878 };
879
880 let has_usable_assertions = fixture.assertions.iter().any(|a| {
882 if a.assertion_type == "error" || a.assertion_type == "not_error" {
883 return false;
884 }
885 match &a.field {
886 Some(f) if !f.is_empty() => field_resolver.is_valid_for_result(f),
887 _ => true,
888 }
889 });
890
891 let mut assertions_body = String::new();
893 for assertion in &fixture.assertions {
894 render_assertion(
895 &mut assertions_body,
896 assertion,
897 result_var,
898 field_resolver,
899 result_is_simple,
900 call_config.result_is_array,
901 );
902 }
903
904 let rendered = crate::template_env::render(
905 "php/test_method.jinja",
906 minijinja::context! {
907 method_name => method_name,
908 description => description,
909 client_factory => client_factory,
910 setup_lines => setup_lines,
911 expects_error => expects_error,
912 skip_test => fixture.assertions.is_empty(),
913 has_usable_assertions => has_usable_assertions,
914 call_expr => call_expr,
915 result_var => result_var,
916 assertions_body => assertions_body,
917 },
918 );
919 out.push_str(&rendered);
920}
921
922fn emit_php_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
935 if let Some(items) = arr.as_array() {
936 let item_strs: Vec<String> = items
937 .iter()
938 .filter_map(|item| {
939 if let Some(obj) = item.as_object() {
940 match elem_type {
941 "BatchBytesItem" => {
942 let content = obj.get("content").and_then(|v| v.as_array());
943 let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
944 let content_code = if let Some(arr) = content {
945 let bytes: Vec<String> = arr
946 .iter()
947 .filter_map(|v| v.as_u64())
948 .map(|n| format!("\\x{:02x}", n))
949 .collect();
950 format!("\"{}\"", bytes.join(""))
951 } else {
952 "\"\"".to_string()
953 };
954 Some(format!(
955 "new {}(content: {}, mimeType: \"{}\")",
956 elem_type, content_code, mime_type
957 ))
958 }
959 "BatchFileItem" => {
960 let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
961 Some(format!("new {}(path: \"{}\")", elem_type, path))
962 }
963 _ => None,
964 }
965 } else {
966 None
967 }
968 })
969 .collect();
970 format!("[{}]", item_strs.join(", "))
971 } else {
972 "[]".to_string()
973 }
974}
975
976fn build_args_and_setup(
977 input: &serde_json::Value,
978 args: &[crate::config::ArgMapping],
979 class_name: &str,
980 _enum_fields: &HashMap<String, String>,
981 fixture_id: &str,
982 options_via: &str,
983 options_type: Option<&str>,
984) -> (Vec<String>, String) {
985 if args.is_empty() {
986 let is_empty_input = match input {
989 serde_json::Value::Null => true,
990 serde_json::Value::Object(m) => m.is_empty(),
991 _ => false,
992 };
993 if is_empty_input {
994 return (Vec::new(), String::new());
995 }
996 return (Vec::new(), json_to_php(input));
997 }
998
999 let mut setup_lines: Vec<String> = Vec::new();
1000 let mut parts: Vec<String> = Vec::new();
1001
1002 let arg_has_emission = |arg: &crate::config::ArgMapping| -> bool {
1007 let val = if arg.field == "input" {
1008 Some(input)
1009 } else {
1010 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1011 input.get(field)
1012 };
1013 match val {
1014 None | Some(serde_json::Value::Null) => !arg.optional,
1015 Some(_) => true,
1016 }
1017 };
1018 let any_later_has_emission = |from_idx: usize| -> bool { args[from_idx..].iter().any(arg_has_emission) };
1019
1020 for (idx, arg) in args.iter().enumerate() {
1021 if arg.arg_type == "mock_url" {
1022 setup_lines.push(format!(
1023 "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
1024 arg.name,
1025 ));
1026 parts.push(format!("${}", arg.name));
1027 continue;
1028 }
1029
1030 if arg.arg_type == "handle" {
1031 let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1033 let config_value = if arg.field == "input" {
1034 input
1035 } else {
1036 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1037 input.get(field).unwrap_or(&serde_json::Value::Null)
1038 };
1039 if config_value.is_null()
1040 || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1041 {
1042 setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
1043 } else {
1044 let name = &arg.name;
1045 let filtered_config = filter_empty_enum_strings(config_value);
1050 setup_lines.push(format!(
1051 "${name}_config = CrawlConfig::from_json(json_encode({}));",
1052 json_to_php(&filtered_config)
1053 ));
1054 setup_lines.push(format!(
1055 "${} = {class_name}::{constructor_name}(${name}_config);",
1056 arg.name,
1057 ));
1058 }
1059 parts.push(format!("${}", arg.name));
1060 continue;
1061 }
1062
1063 let val = if arg.field == "input" {
1064 Some(input)
1065 } else {
1066 let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1067 input.get(field)
1068 };
1069
1070 if arg.arg_type == "bytes" {
1074 match val {
1075 None | Some(serde_json::Value::Null) => {
1076 if arg.optional {
1077 parts.push("null".to_string());
1078 } else {
1079 parts.push("\"\"".to_string());
1080 }
1081 }
1082 Some(serde_json::Value::String(s)) => {
1083 let var_name = format!("{}Bytes", arg.name);
1084 setup_lines.push(format!(
1085 "${var_name} = file_get_contents(\"{path}\");\n if (${var_name} === false) {{ $this->fail(\"failed to read fixture: {path}\"); }}",
1086 path = s.replace('"', "\\\"")
1087 ));
1088 parts.push(format!("${var_name}"));
1089 }
1090 Some(serde_json::Value::Array(arr)) => {
1091 let bytes: String = arr
1092 .iter()
1093 .filter_map(|v| v.as_u64())
1094 .map(|n| format!("\\x{:02x}", n))
1095 .collect();
1096 parts.push(format!("\"{bytes}\""));
1097 }
1098 Some(other) => {
1099 parts.push(json_to_php(other));
1100 }
1101 }
1102 continue;
1103 }
1104
1105 match val {
1106 None | Some(serde_json::Value::Null) if arg.arg_type == "json_object" && arg.name == "config" => {
1107 let type_name = if arg.name == "config" {
1113 "ExtractionConfig".to_string()
1114 } else {
1115 format!("{}Config", arg.name.to_upper_camel_case())
1116 };
1117 parts.push(format!("{type_name}::from_json('{{}}')"));
1118 continue;
1119 }
1120 None | Some(serde_json::Value::Null) if arg.optional => {
1121 if any_later_has_emission(idx + 1) {
1126 parts.push("null".to_string());
1127 }
1128 continue;
1129 }
1130 None | Some(serde_json::Value::Null) => {
1131 let default_val = match arg.arg_type.as_str() {
1133 "string" => "\"\"".to_string(),
1134 "int" | "integer" => "0".to_string(),
1135 "float" | "number" => "0.0".to_string(),
1136 "bool" | "boolean" => "false".to_string(),
1137 "json_object" if options_via == "json" => "null".to_string(),
1138 _ => "null".to_string(),
1139 };
1140 parts.push(default_val);
1141 }
1142 Some(v) => {
1143 if arg.arg_type == "json_object" && !v.is_null() {
1144 if let Some(elem_type) = &arg.element_type {
1146 if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1147 parts.push(emit_php_batch_item_array(v, elem_type));
1148 continue;
1149 }
1150 if v.is_array() && is_php_reserved_type(elem_type) {
1154 parts.push(json_to_php(v));
1155 continue;
1156 }
1157 }
1158 match options_via {
1159 "json" => {
1160 let filtered_v = filter_empty_enum_strings(v);
1163
1164 if let serde_json::Value::Object(obj) = &filtered_v {
1166 if obj.is_empty() {
1167 parts.push("null".to_string());
1168 continue;
1169 }
1170 }
1171
1172 parts.push(format!("json_encode({})", json_to_php_camel_keys(&filtered_v)));
1173 continue;
1174 }
1175 _ => {
1176 if let Some(type_name) = options_type {
1177 let filtered_v = filter_empty_enum_strings(v);
1182
1183 if let serde_json::Value::Object(obj) = &filtered_v {
1186 if obj.is_empty() {
1187 let arg_var = format!("${}", arg.name);
1188 setup_lines.push(format!("{arg_var} = {type_name}::from_json('{{}}');"));
1189 parts.push(arg_var);
1190 continue;
1191 }
1192 }
1193
1194 let arg_var = format!("${}", arg.name);
1195 setup_lines.push(format!(
1199 "{arg_var} = {type_name}::from_json(json_encode({}));",
1200 json_to_php(&filtered_v)
1201 ));
1202 parts.push(arg_var);
1203 continue;
1204 }
1205 if let Some(obj) = v.as_object() {
1209 setup_lines.push("$builder = $this->createDefaultOptionsBuilder();".to_string());
1210 for (k, vv) in obj {
1211 let snake_key = k.to_snake_case();
1212 if snake_key == "preprocessing" {
1213 if let Some(prep_obj) = vv.as_object() {
1214 let enabled =
1215 prep_obj.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
1216 let preset =
1217 prep_obj.get("preset").and_then(|v| v.as_str()).unwrap_or("Minimal");
1218 let remove_navigation = prep_obj
1219 .get("remove_navigation")
1220 .and_then(|v| v.as_bool())
1221 .unwrap_or(true);
1222 let remove_forms =
1223 prep_obj.get("remove_forms").and_then(|v| v.as_bool()).unwrap_or(true);
1224 setup_lines.push(format!(
1225 "$preprocessing = $this->createPreprocessingOptions({}, {}, {}, {});",
1226 if enabled { "true" } else { "false" },
1227 json_to_php(&serde_json::Value::String(preset.to_string())),
1228 if remove_navigation { "true" } else { "false" },
1229 if remove_forms { "true" } else { "false" }
1230 ));
1231 setup_lines.push(
1232 "$builder = $builder->preprocessing($preprocessing);".to_string(),
1233 );
1234 }
1235 }
1236 }
1237 setup_lines.push("$options = $builder->build();".to_string());
1238 parts.push("$options".to_string());
1239 continue;
1240 }
1241 }
1242 }
1243 }
1244 parts.push(json_to_php(v));
1245 }
1246 }
1247 }
1248
1249 (setup_lines, parts.join(", "))
1250}
1251
1252fn render_assertion(
1253 out: &mut String,
1254 assertion: &Assertion,
1255 result_var: &str,
1256 field_resolver: &FieldResolver,
1257 result_is_simple: bool,
1258 result_is_array: bool,
1259) {
1260 if let Some(f) = &assertion.field {
1263 match f.as_str() {
1264 "chunks_have_content" => {
1265 let pred = format!(
1266 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->content), true)"
1267 );
1268 out.push_str(&crate::template_env::render(
1269 "php/synthetic_assertion.jinja",
1270 minijinja::context! {
1271 assertion_kind => "chunks_content",
1272 assertion_type => assertion.assertion_type.as_str(),
1273 pred => pred,
1274 field_name => f,
1275 },
1276 ));
1277 return;
1278 }
1279 "chunks_have_embeddings" => {
1280 let pred = format!(
1281 "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->embedding), true)"
1282 );
1283 out.push_str(&crate::template_env::render(
1284 "php/synthetic_assertion.jinja",
1285 minijinja::context! {
1286 assertion_kind => "chunks_embeddings",
1287 assertion_type => assertion.assertion_type.as_str(),
1288 pred => pred,
1289 field_name => f,
1290 },
1291 ));
1292 return;
1293 }
1294 "embeddings" => {
1298 let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1299 out.push_str(&crate::template_env::render(
1300 "php/synthetic_assertion.jinja",
1301 minijinja::context! {
1302 assertion_kind => "embeddings",
1303 assertion_type => assertion.assertion_type.as_str(),
1304 php_val => php_val,
1305 result_var => result_var,
1306 },
1307 ));
1308 return;
1309 }
1310 "embedding_dimensions" => {
1311 let expr = format!("(empty(${result_var}) ? 0 : count(${result_var}[0]))");
1312 let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1313 out.push_str(&crate::template_env::render(
1314 "php/synthetic_assertion.jinja",
1315 minijinja::context! {
1316 assertion_kind => "embedding_dimensions",
1317 assertion_type => assertion.assertion_type.as_str(),
1318 expr => expr,
1319 php_val => php_val,
1320 },
1321 ));
1322 return;
1323 }
1324 "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1325 let pred = match f.as_str() {
1326 "embeddings_valid" => {
1327 format!("array_reduce(${result_var}, fn($carry, $e) => $carry && count($e) > 0, true)")
1328 }
1329 "embeddings_finite" => {
1330 format!(
1331 "array_reduce(${result_var}, fn($carry, $e) => $carry && array_reduce($e, fn($c, $v) => $c && is_finite($v), true), true)"
1332 )
1333 }
1334 "embeddings_non_zero" => {
1335 format!(
1336 "array_reduce(${result_var}, fn($carry, $e) => $carry && count(array_filter($e, fn($v) => $v !== 0.0)) > 0, true)"
1337 )
1338 }
1339 "embeddings_normalized" => {
1340 format!(
1341 "array_reduce(${result_var}, fn($carry, $e) => $carry && abs(array_sum(array_map(fn($v) => $v * $v, $e)) - 1.0) < 1e-3, true)"
1342 )
1343 }
1344 _ => unreachable!(),
1345 };
1346 let assertion_kind = format!("embeddings_{}", f.strip_prefix("embeddings_").unwrap_or(f));
1347 out.push_str(&crate::template_env::render(
1348 "php/synthetic_assertion.jinja",
1349 minijinja::context! {
1350 assertion_kind => assertion_kind,
1351 assertion_type => assertion.assertion_type.as_str(),
1352 pred => pred,
1353 field_name => f,
1354 },
1355 ));
1356 return;
1357 }
1358 "keywords" | "keywords_count" => {
1361 out.push_str(&crate::template_env::render(
1362 "php/synthetic_assertion.jinja",
1363 minijinja::context! {
1364 assertion_kind => "keywords",
1365 field_name => f,
1366 },
1367 ));
1368 return;
1369 }
1370 _ => {}
1371 }
1372 }
1373
1374 if let Some(f) = &assertion.field {
1376 if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1377 out.push_str(&crate::template_env::render(
1378 "php/synthetic_assertion.jinja",
1379 minijinja::context! {
1380 assertion_kind => "skipped",
1381 field_name => f,
1382 },
1383 ));
1384 return;
1385 }
1386 }
1387
1388 if result_is_simple {
1391 if let Some(f) = &assertion.field {
1392 let f_lower = f.to_lowercase();
1393 if !f.is_empty()
1394 && f_lower != "content"
1395 && (f_lower.starts_with("metadata")
1396 || f_lower.starts_with("document")
1397 || f_lower.starts_with("structure"))
1398 {
1399 out.push_str(&crate::template_env::render(
1400 "php/synthetic_assertion.jinja",
1401 minijinja::context! {
1402 assertion_kind => "result_is_simple",
1403 field_name => f,
1404 },
1405 ));
1406 return;
1407 }
1408 }
1409 }
1410
1411 let field_expr = match &assertion.field {
1412 _ if result_is_simple => format!("${result_var}"),
1416 Some(f) if !f.is_empty() => field_resolver.accessor(f, "php", &format!("${result_var}")),
1417 _ => format!("${result_var}"),
1418 };
1419
1420 let field_is_array = assertion.field.as_ref().map_or(result_is_array, |f| {
1423 if f.is_empty() {
1424 result_is_array
1425 } else {
1426 field_resolver.is_array(f)
1427 }
1428 });
1429
1430 let trimmed_field_expr_for = |expected: &serde_json::Value| -> String {
1434 if expected.is_string() {
1435 format!("trim({})", field_expr)
1436 } else {
1437 field_expr.clone()
1438 }
1439 };
1440
1441 let assertion_type = assertion.assertion_type.as_str();
1443 let has_php_val = assertion.value.is_some();
1444 let php_val = match assertion.value.as_ref() {
1448 Some(v) => json_to_php(v),
1449 None if assertion_type == "equals" => "null".to_string(),
1450 None => String::new(),
1451 };
1452 let trimmed_field_expr = trimmed_field_expr_for(assertion.value.as_ref().unwrap_or(&serde_json::Value::Null));
1453 let is_string_val = assertion.value.as_ref().is_some_and(|v| v.is_string());
1454 let values_php: Vec<String> = assertion
1455 .values
1456 .as_ref()
1457 .map_or(Vec::new(), |vals| vals.iter().map(json_to_php).collect());
1458 let contains_any_checks: Vec<String> = assertion
1459 .values
1460 .as_ref()
1461 .map_or(Vec::new(), |vals| vals.iter().map(json_to_php).collect());
1462 let n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1463
1464 let call_expr = if let Some(method_name) = &assertion.method {
1466 build_php_method_call(result_var, method_name, assertion.args.as_ref())
1467 } else {
1468 String::new()
1469 };
1470 let check = assertion.check.as_deref().unwrap_or("is_true");
1471 let has_php_check_val = matches!(assertion.assertion_type.as_str(), "method_result") && assertion.value.is_some();
1472 let php_check_val = if matches!(assertion.assertion_type.as_str(), "method_result") {
1473 assertion.value.as_ref().map(json_to_php).unwrap_or_default()
1474 } else {
1475 String::new()
1476 };
1477 let check_n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1478 let is_bool_val = assertion.value.as_ref().is_some_and(|v| v.is_boolean());
1479 let bool_is_true = assertion.value.as_ref().and_then(|v| v.as_bool()).unwrap_or(false);
1480
1481 if matches!(assertion_type, "not_error" | "error") {
1483 if assertion_type == "not_error" {
1484 }
1486 return;
1488 }
1489
1490 let rendered = crate::template_env::render(
1491 "php/assertion.jinja",
1492 minijinja::context! {
1493 assertion_type => assertion_type,
1494 field_expr => field_expr,
1495 php_val => php_val,
1496 has_php_val => has_php_val,
1497 trimmed_field_expr => trimmed_field_expr,
1498 is_string_val => is_string_val,
1499 field_is_array => field_is_array,
1500 values_php => values_php,
1501 contains_any_checks => contains_any_checks,
1502 n => n,
1503 call_expr => call_expr,
1504 check => check,
1505 php_check_val => php_check_val,
1506 has_php_check_val => has_php_check_val,
1507 check_n => check_n,
1508 is_bool_val => is_bool_val,
1509 bool_is_true => bool_is_true,
1510 },
1511 );
1512 let _ = write!(out, " {}", rendered);
1513}
1514
1515fn build_php_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1522 let extra_args = if let Some(args_val) = args {
1523 args_val
1524 .as_object()
1525 .map(|obj| {
1526 obj.values()
1527 .map(|v| match v {
1528 serde_json::Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
1529 serde_json::Value::Bool(true) => "true".to_string(),
1530 serde_json::Value::Bool(false) => "false".to_string(),
1531 serde_json::Value::Number(n) => n.to_string(),
1532 serde_json::Value::Null => "null".to_string(),
1533 other => format!("\"{}\"", other.to_string().replace('\\', "\\\\").replace('"', "\\\"")),
1534 })
1535 .collect::<Vec<_>>()
1536 .join(", ")
1537 })
1538 .unwrap_or_default()
1539 } else {
1540 String::new()
1541 };
1542
1543 if extra_args.is_empty() {
1544 format!("${result_var}->{method_name}()")
1545 } else {
1546 format!("${result_var}->{method_name}({extra_args})")
1547 }
1548}
1549
1550fn filter_empty_enum_strings(value: &serde_json::Value) -> serde_json::Value {
1554 match value {
1555 serde_json::Value::Object(map) => {
1556 let filtered: serde_json::Map<String, serde_json::Value> = map
1557 .iter()
1558 .filter_map(|(k, v)| {
1559 if let serde_json::Value::String(s) = v {
1561 if s.is_empty() {
1562 return None;
1563 }
1564 }
1565 Some((k.clone(), filter_empty_enum_strings(v)))
1567 })
1568 .collect();
1569 serde_json::Value::Object(filtered)
1570 }
1571 serde_json::Value::Array(arr) => {
1572 let filtered: Vec<serde_json::Value> = arr.iter().map(filter_empty_enum_strings).collect();
1573 serde_json::Value::Array(filtered)
1574 }
1575 other => other.clone(),
1576 }
1577}
1578
1579fn json_to_php(value: &serde_json::Value) -> String {
1581 match value {
1582 serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
1583 serde_json::Value::Bool(true) => "true".to_string(),
1584 serde_json::Value::Bool(false) => "false".to_string(),
1585 serde_json::Value::Number(n) => n.to_string(),
1586 serde_json::Value::Null => "null".to_string(),
1587 serde_json::Value::Array(arr) => {
1588 let items: Vec<String> = arr.iter().map(json_to_php).collect();
1589 format!("[{}]", items.join(", "))
1590 }
1591 serde_json::Value::Object(map) => {
1592 let items: Vec<String> = map
1593 .iter()
1594 .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
1595 .collect();
1596 format!("[{}]", items.join(", "))
1597 }
1598 }
1599}
1600
1601fn json_to_php_camel_keys(value: &serde_json::Value) -> String {
1606 match value {
1607 serde_json::Value::Object(map) => {
1608 let items: Vec<String> = map
1609 .iter()
1610 .map(|(k, v)| {
1611 let camel_key = k.to_lower_camel_case();
1612 format!("\"{}\" => {}", escape_php(&camel_key), json_to_php_camel_keys(v))
1613 })
1614 .collect();
1615 format!("[{}]", items.join(", "))
1616 }
1617 serde_json::Value::Array(arr) => {
1618 let items: Vec<String> = arr.iter().map(json_to_php_camel_keys).collect();
1619 format!("[{}]", items.join(", "))
1620 }
1621 _ => json_to_php(value),
1622 }
1623}
1624
1625fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
1631 setup_lines.push("$visitor = new class {".to_string());
1632 for (method_name, action) in &visitor_spec.callbacks {
1633 emit_php_visitor_method(setup_lines, method_name, action);
1634 }
1635 setup_lines.push("};".to_string());
1636}
1637
1638fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1640 let params = match method_name {
1641 "visit_link" => "$ctx, $href, $text, $title",
1642 "visit_image" => "$ctx, $src, $alt, $title",
1643 "visit_heading" => "$ctx, $level, $text, $id",
1644 "visit_code_block" => "$ctx, $lang, $code",
1645 "visit_code_inline"
1646 | "visit_strong"
1647 | "visit_emphasis"
1648 | "visit_strikethrough"
1649 | "visit_underline"
1650 | "visit_subscript"
1651 | "visit_superscript"
1652 | "visit_mark"
1653 | "visit_button"
1654 | "visit_summary"
1655 | "visit_figcaption"
1656 | "visit_definition_term"
1657 | "visit_definition_description" => "$ctx, $text",
1658 "visit_text" => "$ctx, $text",
1659 "visit_list_item" => "$ctx, $ordered, $marker, $text",
1660 "visit_blockquote" => "$ctx, $content, $depth",
1661 "visit_table_row" => "$ctx, $cells, $isHeader",
1662 "visit_custom_element" => "$ctx, $tagName, $html",
1663 "visit_form" => "$ctx, $actionUrl, $method",
1664 "visit_input" => "$ctx, $input_type, $name, $value",
1665 "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
1666 "visit_details" => "$ctx, $isOpen",
1667 "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "$ctx, $output",
1668 "visit_list_start" => "$ctx, $ordered",
1669 "visit_list_end" => "$ctx, $ordered, $output",
1670 _ => "$ctx",
1671 };
1672
1673 let (action_type, action_value) = match action {
1674 CallbackAction::Skip => ("skip", String::new()),
1675 CallbackAction::Continue => ("continue", String::new()),
1676 CallbackAction::PreserveHtml => ("preserve_html", String::new()),
1677 CallbackAction::Custom { output } => ("custom", escape_php(output)),
1678 CallbackAction::CustomTemplate { template } => ("custom_template", escape_php(template)),
1679 };
1680
1681 let rendered = crate::template_env::render(
1682 "php/visitor_method.jinja",
1683 minijinja::context! {
1684 method_name => method_name,
1685 params => params,
1686 action_type => action_type,
1687 action_value => action_value,
1688 },
1689 );
1690 for line in rendered.lines() {
1691 setup_lines.push(line.to_string());
1692 }
1693}
1694
1695fn is_php_reserved_type(name: &str) -> bool {
1697 matches!(
1698 name.to_ascii_lowercase().as_str(),
1699 "string"
1700 | "int"
1701 | "integer"
1702 | "float"
1703 | "double"
1704 | "bool"
1705 | "boolean"
1706 | "array"
1707 | "object"
1708 | "null"
1709 | "void"
1710 | "callable"
1711 | "iterable"
1712 | "never"
1713 | "self"
1714 | "parent"
1715 | "static"
1716 | "true"
1717 | "false"
1718 | "mixed"
1719 )
1720}