Skip to main content

alef_e2e/codegen/
php.rs

1//! PHP e2e test generator using PHPUnit.
2//!
3//! Generates `e2e/php/composer.json`, `e2e/php/phpunit.xml`, and
4//! `tests/{Category}Test.php` files from JSON fixtures, driven entirely by
5//! `E2eConfig` and `CallConfig`.
6
7use crate::config::E2eConfig;
8use crate::escape::{escape_php, sanitize_filename};
9use crate::field_access::FieldResolver;
10use crate::fixture::{
11    Assertion, CallbackAction, Fixture, FixtureGroup, HttpFixture, TemplateReturnForm, ValidationErrorExpectation,
12};
13use alef_backend_php::naming::php_autoload_namespace;
14use alef_core::backend::GeneratedFile;
15use alef_core::config::ResolvedCrateConfig;
16use alef_core::hash::{self, CommentStyle};
17use alef_core::ir::TypeRef;
18use alef_core::template_versions as tv;
19use anyhow::Result;
20use heck::{ToLowerCamelCase, ToSnakeCase, ToUpperCamelCase};
21use std::collections::{HashMap, HashSet};
22use std::fmt::Write as FmtWrite;
23use std::path::PathBuf;
24
25use super::E2eCodegen;
26use super::client;
27
28/// PHP e2e code generator.
29pub struct PhpCodegen;
30
31impl E2eCodegen for PhpCodegen {
32    fn generate(
33        &self,
34        groups: &[FixtureGroup],
35        e2e_config: &E2eConfig,
36        config: &ResolvedCrateConfig,
37        type_defs: &[alef_core::ir::TypeDef],
38        enums: &[alef_core::ir::EnumDef],
39    ) -> Result<Vec<GeneratedFile>> {
40        let lang = self.language_name();
41        let output_base = PathBuf::from(e2e_config.effective_output()).join(lang);
42
43        let mut files = Vec::new();
44
45        // Resolve top-level call config to derive class/namespace/factory — these are
46        // shared across all categories. Per-fixture call routing (function name, args)
47        // is resolved inside render_test_method via e2e_config.resolve_call().
48        let call = &e2e_config.call;
49        let overrides = call.overrides.get(lang);
50        let extension_name = config.php_extension_name();
51        let class_name = overrides
52            .and_then(|o| o.class.as_ref())
53            .cloned()
54            .map(|cn| cn.split('\\').next_back().unwrap_or(&cn).to_string())
55            .unwrap_or_else(|| extension_name.to_upper_camel_case());
56        let namespace = overrides.and_then(|o| o.module.as_ref()).cloned().unwrap_or_else(|| {
57            if extension_name.contains('_') {
58                extension_name
59                    .split('_')
60                    .map(|p| p.to_upper_camel_case())
61                    .collect::<Vec<_>>()
62                    .join("\\")
63            } else {
64                extension_name.to_upper_camel_case()
65            }
66        });
67        let empty_enum_fields = HashMap::new();
68        let enum_fields = overrides.map(|o| &o.enum_fields).unwrap_or(&empty_enum_fields);
69        let result_is_simple = overrides.is_some_and(|o| o.result_is_simple);
70        let php_client_factory = overrides.and_then(|o| o.php_client_factory.as_deref());
71        let options_via = overrides.and_then(|o| o.options_via.as_deref()).unwrap_or("array");
72
73        // Resolve package config.
74        let php_pkg = e2e_config.resolve_package("php");
75        let pkg_name = php_pkg
76            .as_ref()
77            .and_then(|p| p.name.as_ref())
78            .cloned()
79            .unwrap_or_else(|| {
80                // Derive `<org>/<module>` from the configured repository URL —
81                // alef is vendor-neutral, so we don't fall back to a fixed org.
82                let org = config
83                    .try_github_repo()
84                    .ok()
85                    .as_deref()
86                    .and_then(alef_core::config::derive_repo_org)
87                    .unwrap_or_else(|| config.name.clone());
88                format!("{org}/{}", call.module.replace('_', "-"))
89            });
90        let pkg_path = php_pkg
91            .as_ref()
92            .and_then(|p| p.path.as_ref())
93            .cloned()
94            .unwrap_or_else(|| "../../packages/php".to_string());
95        let pkg_version = php_pkg
96            .as_ref()
97            .and_then(|p| p.version.as_ref())
98            .cloned()
99            .or_else(|| config.resolved_version())
100            .unwrap_or_else(|| "0.1.0".to_string());
101
102        // Derive the e2e composer project metadata from the consumer-binding
103        // pkg_name (`<vendor>/<crate>`) and the configured PHP autoload
104        // namespace — alef is vendor-neutral, so we don't fall back to a
105        // fixed "kreuzberg" string.
106        let e2e_vendor = pkg_name.split('/').next().unwrap_or(&pkg_name).to_string();
107        let e2e_pkg_name = format!("{e2e_vendor}/e2e-php");
108        // PSR-4 autoload keys appear inside a JSON document, so each PHP
109        // namespace separator must be JSON-escaped (`\` → `\\`). The trailing
110        // pair represents the PHP-mandated trailing `\` (which itself escapes
111        // to `\\` in JSON).
112        let php_namespace_escaped = php_autoload_namespace(config).replace('\\', "\\\\");
113        let e2e_autoload_ns = format!("{php_namespace_escaped}\\\\E2e\\\\");
114
115        // Generate composer.json.
116        files.push(GeneratedFile {
117            path: output_base.join("composer.json"),
118            content: render_composer_json(
119                &e2e_pkg_name,
120                &e2e_autoload_ns,
121                &pkg_name,
122                &pkg_path,
123                &pkg_version,
124                e2e_config.dep_mode,
125            ),
126            generated_header: false,
127        });
128
129        // Generate phpunit.xml.
130        files.push(GeneratedFile {
131            path: output_base.join("phpunit.xml"),
132            content: render_phpunit_xml(),
133            generated_header: false,
134        });
135
136        // Check if any fixture needs a mock HTTP server (either http-shape or
137        // liter-llm mock_response-shape) so bootstrap.php spawns it.
138        let has_http_fixtures = groups
139            .iter()
140            .flat_map(|g| g.fixtures.iter())
141            .any(|f| f.needs_mock_server());
142
143        // Check if any fixture uses file_path or bytes args (needs chdir to test_documents).
144        let has_file_fixtures = groups.iter().flat_map(|g| g.fixtures.iter()).any(|f| {
145            let cc = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
146            cc.args
147                .iter()
148                .any(|a| a.arg_type == "file_path" || a.arg_type == "bytes")
149        });
150
151        // Generate bootstrap.php that loads both autoloaders and optionally starts the mock server.
152        files.push(GeneratedFile {
153            path: output_base.join("bootstrap.php"),
154            content: render_bootstrap(
155                &pkg_path,
156                has_http_fixtures,
157                has_file_fixtures,
158                &e2e_config.test_documents_relative_from(0),
159            ),
160            generated_header: true,
161        });
162
163        // Generate run_tests.php that loads the extension and invokes phpunit.
164        files.push(GeneratedFile {
165            path: output_base.join("run_tests.php"),
166            content: render_run_tests_php(&extension_name, config.php_cargo_crate_name()),
167            generated_header: true,
168        });
169
170        // Generate test files per category.
171        let tests_base = output_base.join("tests");
172
173        // Compute which fields require getter-method syntax in PHP.
174        // ext-php-rs 0.15.x exposes scalar fields as PHP properties via `#[php(prop)]`,
175        // but non-scalar fields (Named structs, Vec<Named>, Map, etc.) need a
176        // `#[php(getter)]` method because `get_method_props` is `todo!()` in
177        // ext-php-rs-derive 0.11.7. E2e assertions must call `->getCamelCase()` for those.
178        let php_enum_names: HashSet<String> = enums.iter().map(|e| e.name.clone()).collect();
179        let php_getter_fields: HashSet<String> = type_defs
180            .iter()
181            .flat_map(|td| td.fields.iter())
182            .filter(|f| !is_php_scalar(&f.ty, &php_enum_names))
183            .map(|f| f.name.clone())
184            .collect();
185
186        let field_resolver = FieldResolver::new_with_php_getters(
187            &e2e_config.fields,
188            &e2e_config.fields_optional,
189            &e2e_config.result_fields,
190            &e2e_config.fields_array,
191            &HashSet::new(),
192            &HashMap::new(),
193            &php_getter_fields,
194        );
195
196        for group in groups {
197            let active: Vec<&Fixture> = group
198                .fixtures
199                .iter()
200                .filter(|f| super::should_include_fixture(f, lang, e2e_config))
201                .collect();
202
203            if active.is_empty() {
204                continue;
205            }
206
207            let test_class = format!("{}Test", sanitize_filename(&group.category).to_upper_camel_case());
208            let filename = format!("{test_class}.php");
209            let content = render_test_file(
210                &group.category,
211                &active,
212                e2e_config,
213                lang,
214                &namespace,
215                &class_name,
216                &test_class,
217                &field_resolver,
218                enum_fields,
219                result_is_simple,
220                php_client_factory,
221                options_via,
222            );
223            files.push(GeneratedFile {
224                path: tests_base.join(filename),
225                content,
226                generated_header: true,
227            });
228        }
229
230        Ok(files)
231    }
232
233    fn language_name(&self) -> &'static str {
234        "php"
235    }
236}
237
238// ---------------------------------------------------------------------------
239// PHP scalar-type predicate
240// ---------------------------------------------------------------------------
241
242/// Returns true when a type is scalar-compatible for ext-php-rs `#[php(prop)]` —
243/// that is, the mapped Rust type implements `IntoZval` + `FromZval` automatically
244/// without a manual getter. Mirrors `is_php_prop_scalar_with_enums` from
245/// `alef-backend-php/src/gen_bindings/types.rs`.
246///
247/// Scalar-compatible: primitives, String, Char, Duration (→ u64), Path (→ String),
248/// Option<scalar>, Vec<primitive|String|Char>, unit-variant enums (mapped to String).
249/// Non-scalar: Named struct, Map, nested Vec<Named>, Json, Bytes.
250fn is_php_scalar(ty: &TypeRef, enum_names: &HashSet<String>) -> bool {
251    match ty {
252        TypeRef::Primitive(_) | TypeRef::String | TypeRef::Char | TypeRef::Duration | TypeRef::Path => true,
253        TypeRef::Optional(inner) => is_php_scalar(inner, enum_names),
254        TypeRef::Vec(inner) => {
255            matches!(inner.as_ref(), TypeRef::Primitive(_) | TypeRef::String | TypeRef::Char)
256                || matches!(inner.as_ref(), TypeRef::Named(n) if enum_names.contains(n))
257        }
258        TypeRef::Named(n) if enum_names.contains(n) => true,
259        TypeRef::Named(_) | TypeRef::Map(_, _) | TypeRef::Json | TypeRef::Bytes | TypeRef::Unit => false,
260    }
261}
262
263// ---------------------------------------------------------------------------
264// Rendering
265// ---------------------------------------------------------------------------
266
267fn render_composer_json(
268    e2e_pkg_name: &str,
269    e2e_autoload_ns: &str,
270    pkg_name: &str,
271    pkg_path: &str,
272    pkg_version: &str,
273    dep_mode: crate::config::DependencyMode,
274) -> String {
275    let (require_section, autoload_section) = match dep_mode {
276        crate::config::DependencyMode::Registry => {
277            let require = format!(
278                r#"  "require": {{
279    "{pkg_name}": "{pkg_version}"
280  }},
281  "require-dev": {{
282    "phpunit/phpunit": "{phpunit}",
283    "guzzlehttp/guzzle": "{guzzle}"
284  }},"#,
285                phpunit = tv::packagist::PHPUNIT,
286                guzzle = tv::packagist::GUZZLE,
287            );
288            (require, String::new())
289        }
290        crate::config::DependencyMode::Local => {
291            let require = format!(
292                r#"  "require-dev": {{
293    "phpunit/phpunit": "{phpunit}",
294    "guzzlehttp/guzzle": "{guzzle}"
295  }},"#,
296                phpunit = tv::packagist::PHPUNIT,
297                guzzle = tv::packagist::GUZZLE,
298            );
299            // For local mode, add autoload for the local package source.
300            // Extract the namespace from pkg_name (org/module) and map it to src/.
301            let pkg_namespace = pkg_name
302                .split('/')
303                .nth(1)
304                .unwrap_or(pkg_name)
305                .split('-')
306                .map(heck::ToUpperCamelCase::to_upper_camel_case)
307                .collect::<Vec<_>>()
308                .join("\\");
309            let autoload = format!(
310                r#"
311  "autoload": {{
312    "psr-4": {{
313      "{}\\": "{}/src/"
314    }}
315  }},"#,
316                pkg_namespace.replace('\\', "\\\\"),
317                pkg_path
318            );
319            (require, autoload)
320        }
321    };
322
323    crate::template_env::render(
324        "php/composer.json.jinja",
325        minijinja::context! {
326            e2e_pkg_name => e2e_pkg_name,
327            e2e_autoload_ns => e2e_autoload_ns,
328            require_section => require_section,
329            autoload_section => autoload_section,
330        },
331    )
332}
333
334fn render_phpunit_xml() -> String {
335    crate::template_env::render("php/phpunit.xml.jinja", minijinja::context! {})
336}
337
338fn render_bootstrap(
339    pkg_path: &str,
340    has_http_fixtures: bool,
341    has_file_fixtures: bool,
342    test_documents_path: &str,
343) -> String {
344    let header = hash::header(CommentStyle::DoubleSlash);
345    crate::template_env::render(
346        "php/bootstrap.php.jinja",
347        minijinja::context! {
348            header => header,
349            pkg_path => pkg_path,
350            has_http_fixtures => has_http_fixtures,
351            has_file_fixtures => has_file_fixtures,
352            test_documents_path => test_documents_path,
353        },
354    )
355}
356
357fn render_run_tests_php(extension_name: &str, cargo_crate_name: Option<&str>) -> String {
358    let header = hash::header(CommentStyle::DoubleSlash);
359    let ext_lib_name = if let Some(crate_name) = cargo_crate_name {
360        // Cargo replaces hyphens with underscores for lib names, and the crate name
361        // already includes the _php suffix.
362        format!("lib{}", crate_name.replace('-', "_"))
363    } else {
364        format!("lib{extension_name}_php")
365    };
366    format!(
367        r#"#!/usr/bin/env php
368<?php
369{header}
370declare(strict_types=1);
371
372// Determine platform-specific extension suffix.
373$extSuffix = match (PHP_OS_FAMILY) {{
374    'Darwin' => '.dylib',
375    default => '.so',
376}};
377$extPath = __DIR__ . '/../../target/release/{ext_lib_name}' . $extSuffix;
378
379// If the locally-built extension exists and we have not already restarted with it,
380// re-exec PHP with no system ini (-n) to avoid conflicts with any system-installed
381// version of the extension, then load the local build explicitly.
382if (file_exists($extPath) && !getenv('ALEF_PHP_LOCAL_EXT_LOADED')) {{
383    putenv('ALEF_PHP_LOCAL_EXT_LOADED=1');
384    $php = PHP_BINARY;
385    $phpunitPath = __DIR__ . '/vendor/bin/phpunit';
386
387    $cmd = array_merge(
388        [$php, '-n', '-d', 'extension=' . $extPath],
389        [$phpunitPath],
390        array_slice($GLOBALS['argv'], 1)
391    );
392
393    passthru(implode(' ', array_map('escapeshellarg', $cmd)), $exitCode);
394    exit($exitCode);
395}}
396
397// Extension is now loaded (via the restart above with -n flag).
398// Invoke PHPUnit normally.
399$phpunitPath = __DIR__ . '/vendor/bin/phpunit';
400if (!file_exists($phpunitPath)) {{
401    echo "PHPUnit not found at $phpunitPath. Run 'composer install' first.\n";
402    exit(1);
403}}
404
405require $phpunitPath;
406"#
407    )
408}
409
410#[allow(clippy::too_many_arguments)]
411fn render_test_file(
412    category: &str,
413    fixtures: &[&Fixture],
414    e2e_config: &E2eConfig,
415    lang: &str,
416    namespace: &str,
417    class_name: &str,
418    test_class: &str,
419    field_resolver: &FieldResolver,
420    enum_fields: &HashMap<String, String>,
421    result_is_simple: bool,
422    php_client_factory: Option<&str>,
423    options_via: &str,
424) -> String {
425    let header = hash::header(CommentStyle::DoubleSlash);
426
427    // Determine if any handle arg has a non-null config (needs CrawlConfig import).
428    let needs_crawl_config_import = fixtures.iter().any(|f| {
429        let call = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
430        call.args.iter().filter(|a| a.arg_type == "handle").any(|a| {
431            let v = f.input.get(&a.field).unwrap_or(&serde_json::Value::Null);
432            !(v.is_null() || v.is_object() && v.as_object().is_some_and(|o| o.is_empty()))
433        })
434    });
435
436    // Determine if any fixture is an HTTP test (needs GuzzleHttp).
437    let has_http_tests = fixtures.iter().any(|f| f.is_http_test());
438
439    // Collect options_type class names that need `use` imports (one import per unique name).
440    let mut options_type_imports: Vec<String> = fixtures
441        .iter()
442        .flat_map(|f| {
443            let call = e2e_config.resolve_call_for_fixture(f.call.as_deref(), &f.input);
444            let php_override = call.overrides.get(lang);
445            let opt_type = php_override.and_then(|o| o.options_type.as_deref()).or_else(|| {
446                e2e_config
447                    .call
448                    .overrides
449                    .get(lang)
450                    .and_then(|o| o.options_type.as_deref())
451            });
452            let element_types: Vec<String> = call
453                .args
454                .iter()
455                .filter_map(|a| a.element_type.as_ref().map(|t| t.to_string()))
456                .filter(|t| !is_php_reserved_type(t))
457                .collect();
458            opt_type.map(|t| t.to_string()).into_iter().chain(element_types)
459        })
460        .collect::<std::collections::HashSet<_>>()
461        .into_iter()
462        .collect();
463    options_type_imports.sort();
464
465    // Build imports_use list
466    let mut imports_use: Vec<String> = Vec::new();
467    if needs_crawl_config_import {
468        imports_use.push(format!("use {namespace}\\CrawlConfig;"));
469    }
470    for type_name in &options_type_imports {
471        if type_name != class_name {
472            imports_use.push(format!("use {namespace}\\{type_name};"));
473        }
474    }
475
476    // Render all test methods
477    let mut fixtures_body = String::new();
478    for (i, fixture) in fixtures.iter().enumerate() {
479        if fixture.is_http_test() {
480            render_http_test_method(&mut fixtures_body, fixture, fixture.http.as_ref().unwrap());
481        } else {
482            render_test_method(
483                &mut fixtures_body,
484                fixture,
485                e2e_config,
486                lang,
487                namespace,
488                class_name,
489                field_resolver,
490                enum_fields,
491                result_is_simple,
492                php_client_factory,
493                options_via,
494            );
495        }
496        if i + 1 < fixtures.len() {
497            fixtures_body.push('\n');
498        }
499    }
500
501    crate::template_env::render(
502        "php/test_file.jinja",
503        minijinja::context! {
504            header => header,
505            namespace => namespace,
506            class_name => class_name,
507            test_class => test_class,
508            category => category,
509            imports_use => imports_use,
510            has_http_tests => has_http_tests,
511            fixtures_body => fixtures_body,
512        },
513    )
514}
515
516// ---------------------------------------------------------------------------
517// HTTP test rendering — shared-driver integration
518// ---------------------------------------------------------------------------
519
520/// Thin renderer that emits PHPUnit test methods targeting a mock server via
521/// Guzzle. Satisfies [`client::TestClientRenderer`] so the shared
522/// [`client::http_call::render_http_test`] driver drives the call sequence.
523struct PhpTestClientRenderer;
524
525impl client::TestClientRenderer for PhpTestClientRenderer {
526    fn language_name(&self) -> &'static str {
527        "php"
528    }
529
530    /// Convert a fixture id to a PHP-valid identifier (snake_case via `sanitize_filename`).
531    fn sanitize_test_name(&self, id: &str) -> String {
532        sanitize_filename(id)
533    }
534
535    /// Emit `/** {description} */ public function test_{fn_name}(): void {`.
536    ///
537    /// When `skip_reason` is `Some`, emits a `markTestSkipped(...)` body and the
538    /// shared driver calls `render_test_close` immediately after, so the closing
539    /// brace is emitted symmetrically.
540    fn render_test_open(&self, out: &mut String, fn_name: &str, description: &str, skip_reason: Option<&str>) {
541        let escaped_reason = skip_reason.map(escape_php);
542        let rendered = crate::template_env::render(
543            "php/http_test_open.jinja",
544            minijinja::context! {
545                fn_name => fn_name,
546                description => description,
547                skip_reason => escaped_reason,
548            },
549        );
550        out.push_str(&rendered);
551    }
552
553    /// Emit the closing `}` for a test method.
554    fn render_test_close(&self, out: &mut String) {
555        let rendered = crate::template_env::render("php/http_test_close.jinja", minijinja::context! {});
556        out.push_str(&rendered);
557    }
558
559    /// Emit a Guzzle request to the mock server's `/fixtures/<fixture_id>` endpoint.
560    ///
561    /// The fixture id is extracted from the path (which the mock server routes as
562    /// `/fixtures/<id>`). `$response` is bound for subsequent assertion methods.
563    fn render_call(&self, out: &mut String, ctx: &client::CallCtx<'_>) {
564        let method = ctx.method.to_uppercase();
565
566        // Build Guzzle options array.
567        let mut opts: Vec<String> = Vec::new();
568
569        if let Some(body) = ctx.body {
570            let php_body = json_to_php(body);
571            opts.push(format!("'json' => {php_body}"));
572        }
573
574        // Merge explicit headers and content_type hint.
575        let mut header_pairs: Vec<String> = Vec::new();
576        if let Some(ct) = ctx.content_type {
577            // Only emit if not already in ctx.headers (avoid duplicate Content-Type).
578            if !ctx.headers.keys().any(|k| k.to_lowercase() == "content-type") {
579                header_pairs.push(format!("\"Content-Type\" => \"{}\"", escape_php(ct)));
580            }
581        }
582        for (k, v) in ctx.headers {
583            header_pairs.push(format!("\"{}\" => \"{}\"", escape_php(k), escape_php(v)));
584        }
585        if !header_pairs.is_empty() {
586            opts.push(format!("'headers' => [{}]", header_pairs.join(", ")));
587        }
588
589        if !ctx.cookies.is_empty() {
590            let cookie_str = ctx
591                .cookies
592                .iter()
593                .map(|(k, v)| format!("{}={}", k, v))
594                .collect::<Vec<_>>()
595                .join("; ");
596            opts.push(format!("'headers' => ['Cookie' => \"{}\"]", escape_php(&cookie_str)));
597        }
598
599        if !ctx.query_params.is_empty() {
600            let pairs: Vec<String> = ctx
601                .query_params
602                .iter()
603                .map(|(k, v)| {
604                    let val_str = match v {
605                        serde_json::Value::String(s) => s.clone(),
606                        other => other.to_string(),
607                    };
608                    format!("\"{}\" => \"{}\"", escape_php(k), escape_php(&val_str))
609                })
610                .collect();
611            opts.push(format!("'query' => [{}]", pairs.join(", ")));
612        }
613
614        let path_lit = format!("\"{}\"", escape_php(ctx.path));
615
616        let rendered = crate::template_env::render(
617            "php/http_request.jinja",
618            minijinja::context! {
619                method => method,
620                path => path_lit,
621                opts => opts,
622                response_var => ctx.response_var,
623            },
624        );
625        out.push_str(&rendered);
626    }
627
628    /// Emit `$this->assertEquals(status, $response->getStatusCode())`.
629    fn render_assert_status(&self, out: &mut String, _response_var: &str, status: u16) {
630        let rendered = crate::template_env::render(
631            "php/http_assertions.jinja",
632            minijinja::context! {
633                response_var => "",
634                status_code => status,
635                headers => Vec::<std::collections::HashMap<&str, String>>::new(),
636                body_assertion => String::new(),
637                partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
638                validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
639            },
640        );
641        out.push_str(&rendered);
642    }
643
644    /// Emit a header assertion using `$response->getHeaderLine(...)` or
645    /// `$response->hasHeader(...)`.
646    ///
647    /// Handles special tokens: `<<present>>`, `<<absent>>`, `<<uuid>>`.
648    fn render_assert_header(&self, out: &mut String, _response_var: &str, name: &str, expected: &str) {
649        let header_key = name.to_lowercase();
650        let header_key_lit = format!("\"{}\"", escape_php(&header_key));
651        let assertion_code = match expected {
652            "<<present>>" => {
653                format!("$this->assertTrue($response->hasHeader({header_key_lit}));")
654            }
655            "<<absent>>" => {
656                format!("$this->assertFalse($response->hasHeader({header_key_lit}));")
657            }
658            "<<uuid>>" => {
659                format!(
660                    "$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}));"
661                )
662            }
663            literal => {
664                let val_lit = format!("\"{}\"", escape_php(literal));
665                format!("$this->assertEquals({val_lit}, $response->getHeaderLine({header_key_lit}));")
666            }
667        };
668
669        let mut headers = vec![std::collections::HashMap::new()];
670        headers[0].insert("assertion_code", assertion_code);
671
672        let rendered = crate::template_env::render(
673            "php/http_assertions.jinja",
674            minijinja::context! {
675                response_var => "",
676                status_code => 0u16,
677                headers => headers,
678                body_assertion => String::new(),
679                partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
680                validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
681            },
682        );
683        out.push_str(&rendered);
684    }
685
686    /// Emit a JSON body equality assertion.
687    ///
688    /// Plain string bodies are compared against `(string) $response->getBody()` directly;
689    /// structured bodies (objects, arrays, booleans, numbers) are decoded via `json_decode`
690    /// and compared with `assertEquals`.
691    fn render_assert_json_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
692        let body_assertion = match expected {
693            serde_json::Value::String(s) if !s.is_empty() => {
694                let php_val = format!("\"{}\"", escape_php(s));
695                format!("$this->assertEquals({php_val}, (string) $response->getBody());")
696            }
697            _ => {
698                let php_val = json_to_php(expected);
699                format!(
700                    "$body = json_decode((string) $response->getBody(), true, 512, JSON_THROW_ON_ERROR);\n        $this->assertEquals({php_val}, $body);"
701                )
702            }
703        };
704
705        let rendered = crate::template_env::render(
706            "php/http_assertions.jinja",
707            minijinja::context! {
708                response_var => "",
709                status_code => 0u16,
710                headers => Vec::<std::collections::HashMap<&str, String>>::new(),
711                body_assertion => body_assertion,
712                partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
713                validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
714            },
715        );
716        out.push_str(&rendered);
717    }
718
719    /// Emit partial body assertions: one `assertEquals` per field in `expected`.
720    fn render_assert_partial_body(&self, out: &mut String, _response_var: &str, expected: &serde_json::Value) {
721        if let Some(obj) = expected.as_object() {
722            let mut partial_body: Vec<std::collections::HashMap<&str, String>> = Vec::new();
723            for (key, val) in obj {
724                let php_key = format!("\"{}\"", escape_php(key));
725                let php_val = json_to_php(val);
726                let assertion_code = format!("$this->assertEquals({php_val}, $body[{php_key}]);");
727                let mut entry = std::collections::HashMap::new();
728                entry.insert("assertion_code", assertion_code);
729                partial_body.push(entry);
730            }
731
732            let rendered = crate::template_env::render(
733                "php/http_assertions.jinja",
734                minijinja::context! {
735                    response_var => "",
736                    status_code => 0u16,
737                    headers => Vec::<std::collections::HashMap<&str, String>>::new(),
738                    body_assertion => String::new(),
739                    partial_body => partial_body,
740                    validation_errors => Vec::<std::collections::HashMap<&str, String>>::new(),
741                },
742            );
743            out.push_str(&rendered);
744        }
745    }
746
747    /// Emit validation-error assertions, checking each expected `msg` against the
748    /// JSON-encoded body string (PHP binding returns ProblemDetails with `errors` array).
749    fn render_assert_validation_errors(
750        &self,
751        out: &mut String,
752        _response_var: &str,
753        errors: &[ValidationErrorExpectation],
754    ) {
755        let mut validation_errors: Vec<std::collections::HashMap<&str, String>> = Vec::new();
756        for err in errors {
757            let msg_lit = format!("\"{}\"", escape_php(&err.msg));
758            let assertion_code =
759                format!("$this->assertStringContainsString({msg_lit}, json_encode($body, JSON_UNESCAPED_SLASHES));");
760            let mut entry = std::collections::HashMap::new();
761            entry.insert("assertion_code", assertion_code);
762            validation_errors.push(entry);
763        }
764
765        let rendered = crate::template_env::render(
766            "php/http_assertions.jinja",
767            minijinja::context! {
768                response_var => "",
769                status_code => 0u16,
770                headers => Vec::<std::collections::HashMap<&str, String>>::new(),
771                body_assertion => String::new(),
772                partial_body => Vec::<std::collections::HashMap<&str, String>>::new(),
773                validation_errors => validation_errors,
774            },
775        );
776        out.push_str(&rendered);
777    }
778}
779
780/// Render a PHPUnit test method for an HTTP server test fixture via the shared driver.
781///
782/// Handles the one PHP-specific pre-condition: HTTP 101 (WebSocket upgrade) causes
783/// cURL/Guzzle to fail; it is emitted as a `markTestSkipped` stub directly.
784fn render_http_test_method(out: &mut String, fixture: &Fixture, http: &HttpFixture) {
785    // HTTP 101 (WebSocket upgrade) causes cURL to treat the connection as an upgrade
786    // and fail with "empty reply from server". Skip these tests in the PHP e2e suite
787    // since Guzzle cannot assert on WebSocket upgrade responses via regular HTTP.
788    if http.expected_response.status_code == 101 {
789        let method_name = sanitize_filename(&fixture.id);
790        let description = &fixture.description;
791        out.push_str(&crate::template_env::render(
792            "php/http_test_skip_101.jinja",
793            minijinja::context! {
794                method_name => method_name,
795                description => description,
796            },
797        ));
798        return;
799    }
800
801    client::http_call::render_http_test(out, &PhpTestClientRenderer, fixture);
802}
803
804// ---------------------------------------------------------------------------
805// Function-call test rendering
806// ---------------------------------------------------------------------------
807
808#[allow(clippy::too_many_arguments)]
809fn render_test_method(
810    out: &mut String,
811    fixture: &Fixture,
812    e2e_config: &E2eConfig,
813    lang: &str,
814    namespace: &str,
815    class_name: &str,
816    field_resolver: &FieldResolver,
817    enum_fields: &HashMap<String, String>,
818    result_is_simple: bool,
819    php_client_factory: Option<&str>,
820    options_via: &str,
821) {
822    // Resolve per-fixture call config: supports named calls via fixture.call field.
823    let call_config = e2e_config.resolve_call_for_fixture(fixture.call.as_deref(), &fixture.input);
824    let call_overrides = call_config.overrides.get(lang);
825    let has_override = call_overrides.is_some_and(|o| o.function.is_some());
826    // Per-call result_is_simple override wins over the language-level default,
827    // so calls like `speech` (returns Vec<u8>) can be marked simple even if
828    // chat/embed are not.
829    let result_is_simple = call_overrides.is_some_and(|o| o.result_is_simple) || result_is_simple;
830    let mut function_name = call_overrides
831        .and_then(|o| o.function.as_ref())
832        .cloned()
833        .unwrap_or_else(|| call_config.function.clone());
834    // The PHP facade exposes async Rust methods under their bare name (no `_async`
835    // suffix) — PHP has no surface-level async, so the facade picks the async
836    // implementation as the default and delegates to `*Async` on the native class.
837    // The `*_sync` variants stay explicit (e.g. `extract_bytes_sync` → `extractBytesSync`).
838    if !has_override {
839        function_name = function_name.to_lower_camel_case();
840    }
841    let result_var = &call_config.result_var;
842    let args = &call_config.args;
843
844    let method_name = sanitize_filename(&fixture.id);
845    let description = &fixture.description;
846    let expects_error = fixture.assertions.iter().any(|a| a.assertion_type == "error");
847
848    // Resolve options_type for this call's PHP override, with fallback to the top-level call override.
849    let call_options_type = call_overrides.and_then(|o| o.options_type.as_deref()).or_else(|| {
850        e2e_config
851            .call
852            .overrides
853            .get(lang)
854            .and_then(|o| o.options_type.as_deref())
855    });
856
857    let (mut setup_lines, args_str) = build_args_and_setup(
858        &fixture.input,
859        args,
860        class_name,
861        enum_fields,
862        fixture,
863        options_via,
864        call_options_type,
865    );
866
867    // Check for skip_languages early
868    let skip_test = call_config.skip_languages.iter().any(|l| l == "php");
869    if skip_test {
870        let rendered = crate::template_env::render(
871            "php/test_method.jinja",
872            minijinja::context! {
873                method_name => method_name,
874                description => description,
875                client_factory => String::new(),
876                setup_lines => Vec::<String>::new(),
877                expects_error => false,
878                skip_test => true,
879                has_usable_assertions => false,
880                call_expr => String::new(),
881                result_var => result_var,
882                assertions_body => String::new(),
883            },
884        );
885        out.push_str(&rendered);
886        return;
887    }
888
889    // Build visitor if present and add to setup
890    let mut options_already_created = !args_str.is_empty() && args_str == "$options";
891    if let Some(visitor_spec) = &fixture.visitor {
892        build_php_visitor(&mut setup_lines, visitor_spec);
893        if !options_already_created {
894            let options_type = call_options_type.unwrap_or("ConversionOptions");
895            setup_lines.push(format!("$builder = \\{namespace}\\{options_type}::builder();"));
896            setup_lines.push("$options = $builder->visitor($visitor)->build();".to_string());
897            options_already_created = true;
898        }
899    }
900
901    let final_args = if options_already_created {
902        if args_str.is_empty() || args_str == "$options" {
903            "$options".to_string()
904        } else {
905            format!("{args_str}, $options")
906        }
907    } else {
908        args_str
909    };
910
911    let call_expr = if php_client_factory.is_some() {
912        format!("$client->{function_name}({final_args})")
913    } else {
914        format!("{class_name}::{function_name}({final_args})")
915    };
916
917    let has_mock = fixture.mock_response.is_some() || fixture.http.is_some();
918    let api_key_var = fixture.env.as_ref().and_then(|e| e.api_key_var.as_deref());
919    let client_factory = if let Some(factory) = php_client_factory {
920        let fixture_id = &fixture.id;
921        if let Some(var) = api_key_var.filter(|_| has_mock) {
922            format!(
923                "$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);"
924            )
925        } else if has_mock {
926            let base_url_expr = if fixture.has_host_root_route() {
927                let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
928                format!("(getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}')")
929            } else {
930                format!("getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}'")
931            };
932            format!("$client = \\{namespace}\\{class_name}::{factory}('test-key', {base_url_expr});")
933        } else if let Some(var) = api_key_var {
934            format!(
935                "$apiKey = getenv('{var}');\n        if (!$apiKey) {{ $this->markTestSkipped('{var} not set'); return; }}\n        $client = \\{namespace}\\{class_name}::{factory}($apiKey);"
936            )
937        } else {
938            format!("$client = \\{namespace}\\{class_name}::{factory}('test-key');")
939        }
940    } else {
941        String::new()
942    };
943
944    // Streaming detection (call-level `streaming` opt-out is honored).
945    let is_streaming = crate::codegen::streaming_assertions::resolve_is_streaming(fixture, call_config.streaming);
946
947    // Determine if there are usable assertions.
948    // For streaming fixtures: streaming virtual fields count as usable.
949    let has_usable_assertions = fixture.assertions.iter().any(|a| {
950        if a.assertion_type == "error" || a.assertion_type == "not_error" {
951            return false;
952        }
953        match &a.field {
954            Some(f) if !f.is_empty() => {
955                if is_streaming && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
956                    return true;
957                }
958                field_resolver.is_valid_for_result(f)
959            }
960            _ => true,
961        }
962    });
963
964    // For streaming fixtures, emit collect snippet after the result assignment.
965    let collect_snippet = if is_streaming {
966        crate::codegen::streaming_assertions::StreamingFieldResolver::collect_snippet("php", result_var, "chunks")
967            .unwrap_or_default()
968    } else {
969        String::new()
970    };
971
972    // Render assertions_body
973    let mut assertions_body = String::new();
974    for assertion in &fixture.assertions {
975        render_assertion(
976            &mut assertions_body,
977            assertion,
978            result_var,
979            field_resolver,
980            result_is_simple,
981            call_config.result_is_array,
982        );
983    }
984
985    // Streaming fixtures whose only assertion is `not_error` produce an empty
986    // assertions_body even though the stream was drained successfully.  PHPUnit
987    // flags such tests as "risky" (no assertions performed).  Emit a minimal
988    // structural assertion against the drained chunk list so the test records
989    // success without false-positive reliance on `expectNotToPerformAssertions`.
990    if is_streaming && !expects_error && assertions_body.trim().is_empty() {
991        assertions_body.push_str("        $this->assertTrue(is_array($chunks), 'expected drained chunks list');\n");
992    }
993
994    let rendered = crate::template_env::render(
995        "php/test_method.jinja",
996        minijinja::context! {
997            method_name => method_name,
998            description => description,
999            client_factory => client_factory,
1000            setup_lines => setup_lines,
1001            expects_error => expects_error,
1002            skip_test => fixture.assertions.is_empty(),
1003            has_usable_assertions => has_usable_assertions || is_streaming,
1004            call_expr => call_expr,
1005            result_var => result_var,
1006            collect_snippet => collect_snippet,
1007            assertions_body => assertions_body,
1008        },
1009    );
1010    out.push_str(&rendered);
1011}
1012
1013/// Build setup lines (e.g. handle creation) and the argument list for the function call.
1014///
1015/// `options_via` controls how `json_object` args are passed:
1016/// - `"array"` (default): PHP array literal `["key" => value, ...]`
1017/// - `"json"`: JSON string via `json_encode([...])` — use when the Rust method accepts `Option<String>`
1018///
1019/// `options_type` is the PHP class name (e.g. `"ProcessConfig"`) used when constructing options
1020/// via `ClassName::from_json(json_encode([...]))`. Required when `options_via` is not `"json"` and
1021/// the binding accepts a typed config object.
1022///
1023/// Returns `(setup_lines, args_string)`.
1024/// Emit PHP batch item array constructors for BatchBytesItem or BatchFileItem arrays.
1025fn emit_php_batch_item_array(arr: &serde_json::Value, elem_type: &str) -> String {
1026    if let Some(items) = arr.as_array() {
1027        let item_strs: Vec<String> = items
1028            .iter()
1029            .filter_map(|item| {
1030                if let Some(obj) = item.as_object() {
1031                    match elem_type {
1032                        "BatchBytesItem" => {
1033                            let content = obj.get("content").and_then(|v| v.as_array());
1034                            let mime_type = obj.get("mime_type").and_then(|v| v.as_str()).unwrap_or("text/plain");
1035                            let content_code = if let Some(arr) = content {
1036                                let bytes: Vec<String> = arr
1037                                    .iter()
1038                                    .filter_map(|v| v.as_u64())
1039                                    .map(|n| format!("\\x{:02x}", n))
1040                                    .collect();
1041                                format!("\"{}\"", bytes.join(""))
1042                            } else {
1043                                "\"\"".to_string()
1044                            };
1045                            Some(format!(
1046                                "new {}(content: {}, mimeType: \"{}\")",
1047                                elem_type, content_code, mime_type
1048                            ))
1049                        }
1050                        "BatchFileItem" => {
1051                            let path = obj.get("path").and_then(|v| v.as_str()).unwrap_or("");
1052                            Some(format!("new {}(path: \"{}\")", elem_type, path))
1053                        }
1054                        _ => None,
1055                    }
1056                } else {
1057                    None
1058                }
1059            })
1060            .collect();
1061        format!("[{}]", item_strs.join(", "))
1062    } else {
1063        "[]".to_string()
1064    }
1065}
1066
1067fn build_args_and_setup(
1068    input: &serde_json::Value,
1069    args: &[crate::config::ArgMapping],
1070    class_name: &str,
1071    _enum_fields: &HashMap<String, String>,
1072    fixture: &crate::fixture::Fixture,
1073    options_via: &str,
1074    options_type: Option<&str>,
1075) -> (Vec<String>, String) {
1076    let fixture_id = &fixture.id;
1077    if args.is_empty() {
1078        // No args configuration: pass the whole input only if it's non-empty.
1079        // Functions with no parameters (e.g. list_models) have empty input and get no args.
1080        let is_empty_input = match input {
1081            serde_json::Value::Null => true,
1082            serde_json::Value::Object(m) => m.is_empty(),
1083            _ => false,
1084        };
1085        if is_empty_input {
1086            return (Vec::new(), String::new());
1087        }
1088        return (Vec::new(), json_to_php(input));
1089    }
1090
1091    let mut setup_lines: Vec<String> = Vec::new();
1092    let mut parts: Vec<String> = Vec::new();
1093
1094    // True when any arg after `from_idx` has a fixture value (or has no fixture
1095    // value but is required — i.e. would emit *something*). Used to decide
1096    // whether a missing optional middle arg must emit `null` to preserve the
1097    // positional argument layout, or can be safely dropped.
1098    let arg_has_emission = |arg: &crate::config::ArgMapping| -> bool {
1099        let val = if arg.field == "input" {
1100            Some(input)
1101        } else {
1102            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1103            input.get(field)
1104        };
1105        match val {
1106            None | Some(serde_json::Value::Null) => !arg.optional,
1107            Some(_) => true,
1108        }
1109    };
1110    let any_later_has_emission = |from_idx: usize| -> bool { args[from_idx..].iter().any(arg_has_emission) };
1111
1112    for (idx, arg) in args.iter().enumerate() {
1113        if arg.arg_type == "mock_url" {
1114            if fixture.has_host_root_route() {
1115                let env_key = format!("MOCK_SERVER_{}", fixture_id.to_uppercase());
1116                setup_lines.push(format!(
1117                    "${} = getenv('{env_key}') ?: getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
1118                    arg.name,
1119                ));
1120            } else {
1121                setup_lines.push(format!(
1122                    "${} = getenv('MOCK_SERVER_URL') . '/fixtures/{fixture_id}';",
1123                    arg.name,
1124                ));
1125            }
1126            parts.push(format!("${}", arg.name));
1127            continue;
1128        }
1129
1130        if arg.arg_type == "handle" {
1131            // Generate a createEngine (or equivalent) call and pass the variable.
1132            let constructor_name = format!("create{}", arg.name.to_upper_camel_case());
1133            let config_value = if arg.field == "input" {
1134                input
1135            } else {
1136                let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1137                input.get(field).unwrap_or(&serde_json::Value::Null)
1138            };
1139            if config_value.is_null()
1140                || config_value.is_object() && config_value.as_object().is_some_and(|o| o.is_empty())
1141            {
1142                setup_lines.push(format!("${} = {class_name}::{constructor_name}(null);", arg.name,));
1143            } else {
1144                let name = &arg.name;
1145                // Use CrawlConfig::from_json() instead of direct property assignment.
1146                // ext-php-rs doesn't support writable #[php(prop)] fields for complex types,
1147                // so serialize the config to JSON and use from_json() to construct it.
1148                // Filter out empty string enum values before passing to from_json().
1149                let filtered_config = filter_empty_enum_strings(config_value);
1150                setup_lines.push(format!(
1151                    "${name}_config = CrawlConfig::from_json(json_encode({}));",
1152                    json_to_php_camel_keys(&filtered_config)
1153                ));
1154                setup_lines.push(format!(
1155                    "${} = {class_name}::{constructor_name}(${name}_config);",
1156                    arg.name,
1157                ));
1158            }
1159            parts.push(format!("${}", arg.name));
1160            continue;
1161        }
1162
1163        let val = if arg.field == "input" {
1164            Some(input)
1165        } else {
1166            let field = arg.field.strip_prefix("input.").unwrap_or(&arg.field);
1167            input.get(field)
1168        };
1169
1170        // Bytes args: fixture stores either a fixture-relative path string (load
1171        // with file_get_contents at runtime, mirroring the go/python convention)
1172        // or an inline byte array (encode as a "\xNN" escape string).
1173        if arg.arg_type == "bytes" {
1174            match val {
1175                None | Some(serde_json::Value::Null) => {
1176                    if arg.optional {
1177                        parts.push("null".to_string());
1178                    } else {
1179                        parts.push("\"\"".to_string());
1180                    }
1181                }
1182                Some(serde_json::Value::String(s)) => {
1183                    let var_name = format!("{}Bytes", arg.name);
1184                    setup_lines.push(format!(
1185                        "${var_name} = file_get_contents(\"{path}\");\n        if (${var_name} === false) {{ $this->fail(\"failed to read fixture: {path}\"); }}",
1186                        path = s.replace('"', "\\\"")
1187                    ));
1188                    parts.push(format!("${var_name}"));
1189                }
1190                Some(serde_json::Value::Array(arr)) => {
1191                    let bytes: String = arr
1192                        .iter()
1193                        .filter_map(|v| v.as_u64())
1194                        .map(|n| format!("\\x{:02x}", n))
1195                        .collect();
1196                    parts.push(format!("\"{bytes}\""));
1197                }
1198                Some(other) => {
1199                    parts.push(json_to_php(other));
1200                }
1201            }
1202            continue;
1203        }
1204
1205        match val {
1206            None | Some(serde_json::Value::Null) if arg.arg_type == "json_object" && arg.name == "config" => {
1207                // Special case: ExtractionConfig and similar config objects with no fixture value
1208                // should default to an empty instance (e.g., ExtractionConfig::from_json('{}'))
1209                // to satisfy required parameters. This check happens BEFORE the optional check
1210                // so that config args are always provided, even if marked optional in alef.toml.
1211                // Infer the type name from the arg name and capitalize it (e.g., "config" -> "ExtractionConfig").
1212                let type_name = if arg.name == "config" {
1213                    "ExtractionConfig".to_string()
1214                } else {
1215                    format!("{}Config", arg.name.to_upper_camel_case())
1216                };
1217                parts.push(format!("{type_name}::from_json('{{}}')"));
1218                continue;
1219            }
1220            None | Some(serde_json::Value::Null) if arg.optional => {
1221                // Optional arg with no fixture value. If a later arg WILL emit
1222                // something, we must keep this slot in place by passing `null`
1223                // so the positional argument layout matches the PHP signature.
1224                // Otherwise drop the trailing optional argument entirely.
1225                if any_later_has_emission(idx + 1) {
1226                    parts.push("null".to_string());
1227                }
1228                continue;
1229            }
1230            None | Some(serde_json::Value::Null) => {
1231                // Required arg with no fixture value: pass a language-appropriate default.
1232                let default_val = match arg.arg_type.as_str() {
1233                    "string" => "\"\"".to_string(),
1234                    "int" | "integer" => "0".to_string(),
1235                    "float" | "number" => "0.0".to_string(),
1236                    "bool" | "boolean" => "false".to_string(),
1237                    "json_object" if options_via == "json" => "null".to_string(),
1238                    _ => "null".to_string(),
1239                };
1240                parts.push(default_val);
1241            }
1242            Some(v) => {
1243                if arg.arg_type == "json_object" && !v.is_null() {
1244                    // Check for batch item arrays first
1245                    if let Some(elem_type) = &arg.element_type {
1246                        if (elem_type == "BatchBytesItem" || elem_type == "BatchFileItem") && v.is_array() {
1247                            parts.push(emit_php_batch_item_array(v, elem_type));
1248                            continue;
1249                        }
1250                        // When element_type is a scalar/primitive and value is an array,
1251                        // pass it directly as a PHP array (e.g. ["python"]) rather than
1252                        // wrapping in a typed config constructor.
1253                        if v.is_array() && is_php_reserved_type(elem_type) {
1254                            parts.push(json_to_php(v));
1255                            continue;
1256                        }
1257                    }
1258                    match options_via {
1259                        "json" => {
1260                            // Pass as JSON string via json_encode(); the Rust method accepts Option<String>.
1261                            // Filter out empty string enum values.
1262                            let filtered_v = filter_empty_enum_strings(v);
1263
1264                            // If the config is empty after filtering, pass null instead.
1265                            if let serde_json::Value::Object(obj) = &filtered_v {
1266                                if obj.is_empty() {
1267                                    parts.push("null".to_string());
1268                                    continue;
1269                                }
1270                            }
1271
1272                            parts.push(format!("json_encode({})", json_to_php_camel_keys(&filtered_v)));
1273                            continue;
1274                        }
1275                        _ => {
1276                            if let Some(type_name) = options_type {
1277                                // Use TypeName::from_json(json_encode([...])) to construct the
1278                                // typed config object. ext-php-rs structs expose a from_json()
1279                                // static method that accepts a JSON string.
1280                                // Filter out empty string enum values before passing to from_json().
1281                                let filtered_v = filter_empty_enum_strings(v);
1282
1283                                // For empty objects, construct with from_json('{}') to get the
1284                                // type's defaults rather than passing null (which fails for non-optional params).
1285                                if let serde_json::Value::Object(obj) = &filtered_v {
1286                                    if obj.is_empty() {
1287                                        let arg_var = format!("${}", arg.name);
1288                                        setup_lines.push(format!("{arg_var} = {type_name}::from_json('{{}}');"));
1289                                        parts.push(arg_var);
1290                                        continue;
1291                                    }
1292                                }
1293
1294                                let arg_var = format!("${}", arg.name);
1295                                // PHP binding's serde wrapper applies `rename_all` per the per-language
1296                                // wire-case registry (`ResolvedCrateConfig::serde_rename_all_for_language`),
1297                                // which is camelCase for PHP — emit camelCase keys to match.
1298                                setup_lines.push(format!(
1299                                    "{arg_var} = {type_name}::from_json(json_encode({}));",
1300                                    json_to_php_camel_keys(&filtered_v)
1301                                ));
1302                                parts.push(arg_var);
1303                                continue;
1304                            }
1305                            // Fallback: builder pattern when no options_type is configured.
1306                            // This path is kept for backwards compatibility with projects
1307                            // that use a builder-style API without from_json().
1308                            if let Some(obj) = v.as_object() {
1309                                setup_lines.push("$builder = $this->createDefaultOptionsBuilder();".to_string());
1310                                for (k, vv) in obj {
1311                                    let snake_key = k.to_snake_case();
1312                                    if snake_key == "preprocessing" {
1313                                        if let Some(prep_obj) = vv.as_object() {
1314                                            let enabled =
1315                                                prep_obj.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
1316                                            let preset =
1317                                                prep_obj.get("preset").and_then(|v| v.as_str()).unwrap_or("Minimal");
1318                                            let remove_navigation = prep_obj
1319                                                .get("remove_navigation")
1320                                                .and_then(|v| v.as_bool())
1321                                                .unwrap_or(true);
1322                                            let remove_forms =
1323                                                prep_obj.get("remove_forms").and_then(|v| v.as_bool()).unwrap_or(true);
1324                                            setup_lines.push(format!(
1325                                                "$preprocessing = $this->createPreprocessingOptions({}, {}, {}, {});",
1326                                                if enabled { "true" } else { "false" },
1327                                                json_to_php(&serde_json::Value::String(preset.to_string())),
1328                                                if remove_navigation { "true" } else { "false" },
1329                                                if remove_forms { "true" } else { "false" }
1330                                            ));
1331                                            setup_lines.push(
1332                                                "$builder = $builder->preprocessing($preprocessing);".to_string(),
1333                                            );
1334                                        }
1335                                    }
1336                                }
1337                                setup_lines.push("$options = $builder->build();".to_string());
1338                                parts.push("$options".to_string());
1339                                continue;
1340                            }
1341                        }
1342                    }
1343                }
1344                parts.push(json_to_php(v));
1345            }
1346        }
1347    }
1348
1349    (setup_lines, parts.join(", "))
1350}
1351
1352fn render_assertion(
1353    out: &mut String,
1354    assertion: &Assertion,
1355    result_var: &str,
1356    field_resolver: &FieldResolver,
1357    result_is_simple: bool,
1358    result_is_array: bool,
1359) {
1360    // Handle synthetic / derived fields before the is_valid_for_result check
1361    // so they are never treated as struct property accesses on the result.
1362    if let Some(f) = &assertion.field {
1363        match f.as_str() {
1364            "chunks_have_content" => {
1365                let pred = format!(
1366                    "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->content), true)"
1367                );
1368                out.push_str(&crate::template_env::render(
1369                    "php/synthetic_assertion.jinja",
1370                    minijinja::context! {
1371                        assertion_kind => "chunks_content",
1372                        assertion_type => assertion.assertion_type.as_str(),
1373                        pred => pred,
1374                        field_name => f,
1375                    },
1376                ));
1377                return;
1378            }
1379            "chunks_have_embeddings" => {
1380                let pred = format!(
1381                    "array_reduce(${result_var}->chunks ?? [], fn($carry, $c) => $carry && !empty($c->embedding), true)"
1382                );
1383                out.push_str(&crate::template_env::render(
1384                    "php/synthetic_assertion.jinja",
1385                    minijinja::context! {
1386                        assertion_kind => "chunks_embeddings",
1387                        assertion_type => assertion.assertion_type.as_str(),
1388                        pred => pred,
1389                        field_name => f,
1390                    },
1391                ));
1392                return;
1393            }
1394            // ---- EmbedResponse virtual fields ----
1395            // embed_texts returns array<array<float>> in PHP — no wrapper object.
1396            // $result_var is the embedding matrix; use it directly.
1397            "embeddings" => {
1398                let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1399                out.push_str(&crate::template_env::render(
1400                    "php/synthetic_assertion.jinja",
1401                    minijinja::context! {
1402                        assertion_kind => "embeddings",
1403                        assertion_type => assertion.assertion_type.as_str(),
1404                        php_val => php_val,
1405                        result_var => result_var,
1406                    },
1407                ));
1408                return;
1409            }
1410            "embedding_dimensions" => {
1411                let expr = format!("(empty(${result_var}) ? 0 : count(${result_var}[0]))");
1412                let php_val = assertion.value.as_ref().map(json_to_php).unwrap_or_default();
1413                out.push_str(&crate::template_env::render(
1414                    "php/synthetic_assertion.jinja",
1415                    minijinja::context! {
1416                        assertion_kind => "embedding_dimensions",
1417                        assertion_type => assertion.assertion_type.as_str(),
1418                        expr => expr,
1419                        php_val => php_val,
1420                    },
1421                ));
1422                return;
1423            }
1424            "embeddings_valid" | "embeddings_finite" | "embeddings_non_zero" | "embeddings_normalized" => {
1425                let pred = match f.as_str() {
1426                    "embeddings_valid" => {
1427                        format!("array_reduce(${result_var}, fn($carry, $e) => $carry && count($e) > 0, true)")
1428                    }
1429                    "embeddings_finite" => {
1430                        format!(
1431                            "array_reduce(${result_var}, fn($carry, $e) => $carry && array_reduce($e, fn($c, $v) => $c && is_finite($v), true), true)"
1432                        )
1433                    }
1434                    "embeddings_non_zero" => {
1435                        format!(
1436                            "array_reduce(${result_var}, fn($carry, $e) => $carry && count(array_filter($e, fn($v) => $v !== 0.0)) > 0, true)"
1437                        )
1438                    }
1439                    "embeddings_normalized" => {
1440                        format!(
1441                            "array_reduce(${result_var}, fn($carry, $e) => $carry && abs(array_sum(array_map(fn($v) => $v * $v, $e)) - 1.0) < 1e-3, true)"
1442                        )
1443                    }
1444                    _ => unreachable!(),
1445                };
1446                let assertion_kind = format!("embeddings_{}", f.strip_prefix("embeddings_").unwrap_or(f));
1447                out.push_str(&crate::template_env::render(
1448                    "php/synthetic_assertion.jinja",
1449                    minijinja::context! {
1450                        assertion_kind => assertion_kind,
1451                        assertion_type => assertion.assertion_type.as_str(),
1452                        pred => pred,
1453                        field_name => f,
1454                    },
1455                ));
1456                return;
1457            }
1458            // ---- keywords / keywords_count ----
1459            // PHP ExtractionResult does not expose extracted_keywords; skip.
1460            "keywords" | "keywords_count" => {
1461                out.push_str(&crate::template_env::render(
1462                    "php/synthetic_assertion.jinja",
1463                    minijinja::context! {
1464                        assertion_kind => "keywords",
1465                        field_name => f,
1466                    },
1467                ));
1468                return;
1469            }
1470            _ => {}
1471        }
1472    }
1473
1474    // Streaming virtual fields: intercept before is_valid_for_result so they are
1475    // never skipped.  These fields resolve against the `$chunks` collected-list variable.
1476    if let Some(f) = &assertion.field {
1477        if !f.is_empty() && crate::codegen::streaming_assertions::is_streaming_virtual_field(f) {
1478            if let Some(expr) =
1479                crate::codegen::streaming_assertions::StreamingFieldResolver::accessor(f, "php", "chunks")
1480            {
1481                let line = match assertion.assertion_type.as_str() {
1482                    "count_min" => {
1483                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1484                            format!(
1485                                "        $this->assertGreaterThanOrEqual({n}, count({expr}), 'expected >= {n} chunks');\n"
1486                            )
1487                        } else {
1488                            String::new()
1489                        }
1490                    }
1491                    "count_equals" => {
1492                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1493                            format!("        $this->assertCount({n}, {expr});\n")
1494                        } else {
1495                            String::new()
1496                        }
1497                    }
1498                    "equals" => {
1499                        if let Some(serde_json::Value::String(s)) = &assertion.value {
1500                            let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
1501                            format!("        $this->assertEquals('{escaped}', {expr});\n")
1502                        } else if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1503                            format!("        $this->assertEquals({n}, {expr});\n")
1504                        } else {
1505                            String::new()
1506                        }
1507                    }
1508                    "not_empty" => format!("        $this->assertNotEmpty({expr});\n"),
1509                    "is_empty" => format!("        $this->assertEmpty({expr});\n"),
1510                    "is_true" => format!("        $this->assertTrue({expr});\n"),
1511                    "is_false" => format!("        $this->assertFalse({expr});\n"),
1512                    "greater_than" => {
1513                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1514                            format!("        $this->assertGreaterThan({n}, {expr});\n")
1515                        } else {
1516                            String::new()
1517                        }
1518                    }
1519                    "greater_than_or_equal" => {
1520                        if let Some(n) = assertion.value.as_ref().and_then(|v| v.as_u64()) {
1521                            format!("        $this->assertGreaterThanOrEqual({n}, {expr});\n")
1522                        } else {
1523                            String::new()
1524                        }
1525                    }
1526                    "contains" => {
1527                        if let Some(serde_json::Value::String(s)) = &assertion.value {
1528                            let escaped = s.replace('\\', "\\\\").replace('\'', "\\'");
1529                            format!("        $this->assertStringContainsString('{escaped}', {expr});\n")
1530                        } else {
1531                            String::new()
1532                        }
1533                    }
1534                    _ => format!(
1535                        "        // streaming field '{f}': assertion type '{}' not rendered\n",
1536                        assertion.assertion_type
1537                    ),
1538                };
1539                if !line.is_empty() {
1540                    out.push_str(&line);
1541                }
1542            }
1543            return;
1544        }
1545    }
1546
1547    // Skip assertions on fields that don't exist on the result type.
1548    if let Some(f) = &assertion.field {
1549        if !f.is_empty() && !field_resolver.is_valid_for_result(f) {
1550            out.push_str(&crate::template_env::render(
1551                "php/synthetic_assertion.jinja",
1552                minijinja::context! {
1553                    assertion_kind => "skipped",
1554                    field_name => f,
1555                },
1556            ));
1557            return;
1558        }
1559    }
1560
1561    // When result_is_simple, skip assertions that reference non-content fields
1562    // (e.g., metadata, document, structure) since the binding returns a plain value.
1563    if result_is_simple {
1564        if let Some(f) = &assertion.field {
1565            let f_lower = f.to_lowercase();
1566            if !f.is_empty()
1567                && f_lower != "content"
1568                && (f_lower.starts_with("metadata")
1569                    || f_lower.starts_with("document")
1570                    || f_lower.starts_with("structure"))
1571            {
1572                out.push_str(&crate::template_env::render(
1573                    "php/synthetic_assertion.jinja",
1574                    minijinja::context! {
1575                        assertion_kind => "result_is_simple",
1576                        field_name => f,
1577                    },
1578                ));
1579                return;
1580            }
1581        }
1582    }
1583
1584    let field_expr = match &assertion.field {
1585        // When result_is_simple, the result is a scalar (bytes/string/etc.) — any
1586        // field access on it would fail. Treat all assertions as referring to the
1587        // result itself.
1588        _ if result_is_simple => format!("${result_var}"),
1589        Some(f) if !f.is_empty() => field_resolver.accessor(f, "php", &format!("${result_var}")),
1590        _ => format!("${result_var}"),
1591    };
1592
1593    // Detect if this field is an array type
1594    // When there's no field, default to result_is_array (the result itself is the array)
1595    let field_is_array = assertion.field.as_ref().map_or(result_is_array, |f| {
1596        if f.is_empty() {
1597            result_is_array
1598        } else {
1599            field_resolver.is_array(f)
1600        }
1601    });
1602
1603    // For string equality, trim trailing whitespace to handle trailing newlines.
1604    // Only apply trim() when the expected value is a string — calling trim() on int/bool
1605    // throws TypeError in PHP 8.4+.
1606    let trimmed_field_expr_for = |expected: &serde_json::Value| -> String {
1607        if expected.is_string() {
1608            format!("trim({})", field_expr)
1609        } else {
1610            field_expr.clone()
1611        }
1612    };
1613
1614    // Prepare template context.
1615    let assertion_type = assertion.assertion_type.as_str();
1616    let has_php_val = assertion.value.is_some();
1617    // serde collapses `"value": null` to `None`, but `equals` against null is a real
1618    // assertion (e.g. `result.message.content == null`). Default to PHP `null` in that
1619    // case so the rendered code compiles instead of producing `assertEquals(, ...)`.
1620    let php_val = match assertion.value.as_ref() {
1621        Some(v) => json_to_php(v),
1622        None if assertion_type == "equals" => "null".to_string(),
1623        None => String::new(),
1624    };
1625    let trimmed_field_expr = trimmed_field_expr_for(assertion.value.as_ref().unwrap_or(&serde_json::Value::Null));
1626    let is_string_val = assertion.value.as_ref().is_some_and(|v| v.is_string());
1627    // values_php is consumed by `contains`, `contains_all`, and `not_contains` loops.
1628    // Fall back to wrapping the singular `value` so single-entry fixtures still emit one
1629    // assertion call per value instead of an empty loop.
1630    let values_php: Vec<String> = assertion
1631        .values
1632        .as_ref()
1633        .map(|vals| vals.iter().map(json_to_php).collect::<Vec<_>>())
1634        .or_else(|| assertion.value.as_ref().map(|v| vec![json_to_php(v)]))
1635        .unwrap_or_default();
1636    let contains_any_checks: Vec<String> = assertion
1637        .values
1638        .as_ref()
1639        .map_or(Vec::new(), |vals| vals.iter().map(json_to_php).collect());
1640    let n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1641
1642    // For method_result assertions.
1643    let call_expr = if let Some(method_name) = &assertion.method {
1644        build_php_method_call(result_var, method_name, assertion.args.as_ref())
1645    } else {
1646        String::new()
1647    };
1648    let check = assertion.check.as_deref().unwrap_or("is_true");
1649    let has_php_check_val = matches!(assertion.assertion_type.as_str(), "method_result") && assertion.value.is_some();
1650    let php_check_val = if matches!(assertion.assertion_type.as_str(), "method_result") {
1651        assertion.value.as_ref().map(json_to_php).unwrap_or_default()
1652    } else {
1653        String::new()
1654    };
1655    let check_n = assertion.value.as_ref().and_then(|v| v.as_u64()).unwrap_or(0);
1656    let is_bool_val = assertion.value.as_ref().is_some_and(|v| v.is_boolean());
1657    let bool_is_true = assertion.value.as_ref().and_then(|v| v.as_bool()).unwrap_or(false);
1658
1659    // Early returns for non-template-renderable assertions.
1660    if matches!(assertion_type, "not_error" | "error") {
1661        if assertion_type == "not_error" {
1662            // Already handled by the call succeeding without exception.
1663        }
1664        // "error" is handled at the test method level.
1665        return;
1666    }
1667
1668    let rendered = crate::template_env::render(
1669        "php/assertion.jinja",
1670        minijinja::context! {
1671            assertion_type => assertion_type,
1672            field_expr => field_expr,
1673            php_val => php_val,
1674            has_php_val => has_php_val,
1675            trimmed_field_expr => trimmed_field_expr,
1676            is_string_val => is_string_val,
1677            field_is_array => field_is_array,
1678            values_php => values_php,
1679            contains_any_checks => contains_any_checks,
1680            n => n,
1681            call_expr => call_expr,
1682            check => check,
1683            php_check_val => php_check_val,
1684            has_php_check_val => has_php_check_val,
1685            check_n => check_n,
1686            is_bool_val => is_bool_val,
1687            bool_is_true => bool_is_true,
1688        },
1689    );
1690    let _ = write!(out, "        {}", rendered);
1691}
1692
1693/// Build a PHP call expression for a `method_result` assertion.
1694///
1695/// Uses generic instance method dispatch: `$result_var->method_name(args...)`.
1696/// Args from the fixture JSON object are emitted as positional PHP arguments in
1697/// insertion order, using best-effort type conversion (strings → PHP string literals,
1698/// numbers and booleans → verbatim literals).
1699fn build_php_method_call(result_var: &str, method_name: &str, args: Option<&serde_json::Value>) -> String {
1700    let extra_args = if let Some(args_val) = args {
1701        args_val
1702            .as_object()
1703            .map(|obj| {
1704                obj.values()
1705                    .map(|v| match v {
1706                        serde_json::Value::String(s) => format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")),
1707                        serde_json::Value::Bool(true) => "true".to_string(),
1708                        serde_json::Value::Bool(false) => "false".to_string(),
1709                        serde_json::Value::Number(n) => n.to_string(),
1710                        serde_json::Value::Null => "null".to_string(),
1711                        other => format!("\"{}\"", other.to_string().replace('\\', "\\\\").replace('"', "\\\"")),
1712                    })
1713                    .collect::<Vec<_>>()
1714                    .join(", ")
1715            })
1716            .unwrap_or_default()
1717    } else {
1718        String::new()
1719    };
1720
1721    if extra_args.is_empty() {
1722        format!("${result_var}->{method_name}()")
1723    } else {
1724        format!("${result_var}->{method_name}({extra_args})")
1725    }
1726}
1727
1728/// Filters out empty string enum values from JSON objects before rendering.
1729/// When a field has an empty string value, it's treated as a missing/null enum field
1730/// and should not be included in the PHP array.
1731fn filter_empty_enum_strings(value: &serde_json::Value) -> serde_json::Value {
1732    match value {
1733        serde_json::Value::Object(map) => {
1734            let filtered: serde_json::Map<String, serde_json::Value> = map
1735                .iter()
1736                .filter_map(|(k, v)| {
1737                    // Skip empty string values (typically represent missing enum variants)
1738                    if let serde_json::Value::String(s) = v {
1739                        if s.is_empty() {
1740                            return None;
1741                        }
1742                    }
1743                    // Recursively filter nested objects and arrays
1744                    Some((k.clone(), filter_empty_enum_strings(v)))
1745                })
1746                .collect();
1747            serde_json::Value::Object(filtered)
1748        }
1749        serde_json::Value::Array(arr) => {
1750            let filtered: Vec<serde_json::Value> = arr.iter().map(filter_empty_enum_strings).collect();
1751            serde_json::Value::Array(filtered)
1752        }
1753        other => other.clone(),
1754    }
1755}
1756
1757/// Convert a `serde_json::Value` to a PHP literal string.
1758fn json_to_php(value: &serde_json::Value) -> String {
1759    match value {
1760        serde_json::Value::String(s) => format!("\"{}\"", escape_php(s)),
1761        serde_json::Value::Bool(true) => "true".to_string(),
1762        serde_json::Value::Bool(false) => "false".to_string(),
1763        serde_json::Value::Number(n) => n.to_string(),
1764        serde_json::Value::Null => "null".to_string(),
1765        serde_json::Value::Array(arr) => {
1766            let items: Vec<String> = arr.iter().map(json_to_php).collect();
1767            format!("[{}]", items.join(", "))
1768        }
1769        serde_json::Value::Object(map) => {
1770            let items: Vec<String> = map
1771                .iter()
1772                .map(|(k, v)| format!("\"{}\" => {}", escape_php(k), json_to_php(v)))
1773                .collect();
1774            format!("[{}]", items.join(", "))
1775        }
1776    }
1777}
1778
1779/// Like `json_to_php` but recursively converts all object keys to lowerCamelCase.
1780/// Used when generating PHP option arrays passed to `from_json()` — the PHP binding
1781/// structs use `#[serde(rename_all = "camelCase")]` so snake_case fixture keys
1782/// (e.g. `remove_forms`) must become `removeForms` in the generated test code.
1783fn json_to_php_camel_keys(value: &serde_json::Value) -> String {
1784    match value {
1785        serde_json::Value::Object(map) => {
1786            let items: Vec<String> = map
1787                .iter()
1788                .map(|(k, v)| {
1789                    let camel_key = k.to_lower_camel_case();
1790                    format!("\"{}\" => {}", escape_php(&camel_key), json_to_php_camel_keys(v))
1791                })
1792                .collect();
1793            format!("[{}]", items.join(", "))
1794        }
1795        serde_json::Value::Array(arr) => {
1796            let items: Vec<String> = arr.iter().map(json_to_php_camel_keys).collect();
1797            format!("[{}]", items.join(", "))
1798        }
1799        _ => json_to_php(value),
1800    }
1801}
1802
1803// ---------------------------------------------------------------------------
1804// Visitor generation
1805// ---------------------------------------------------------------------------
1806
1807/// Build a PHP visitor object and add setup lines. The visitor is assigned to $visitor variable.
1808fn build_php_visitor(setup_lines: &mut Vec<String>, visitor_spec: &crate::fixture::VisitorSpec) {
1809    setup_lines.push("$visitor = new class {".to_string());
1810    for (method_name, action) in &visitor_spec.callbacks {
1811        emit_php_visitor_method(setup_lines, method_name, action);
1812    }
1813    setup_lines.push("};".to_string());
1814}
1815
1816/// Emit a PHP visitor method for a callback action.
1817fn emit_php_visitor_method(setup_lines: &mut Vec<String>, method_name: &str, action: &CallbackAction) {
1818    let params = match method_name {
1819        "visit_link" => "$ctx, $href, $text, $title",
1820        "visit_image" => "$ctx, $src, $alt, $title",
1821        "visit_heading" => "$ctx, $level, $text, $id",
1822        "visit_code_block" => "$ctx, $lang, $code",
1823        "visit_code_inline"
1824        | "visit_strong"
1825        | "visit_emphasis"
1826        | "visit_strikethrough"
1827        | "visit_underline"
1828        | "visit_subscript"
1829        | "visit_superscript"
1830        | "visit_mark"
1831        | "visit_button"
1832        | "visit_summary"
1833        | "visit_figcaption"
1834        | "visit_definition_term"
1835        | "visit_definition_description" => "$ctx, $text",
1836        "visit_text" => "$ctx, $text",
1837        "visit_list_item" => "$ctx, $ordered, $marker, $text",
1838        "visit_blockquote" => "$ctx, $content, $depth",
1839        "visit_table_row" => "$ctx, $cells, $isHeader",
1840        "visit_custom_element" => "$ctx, $tagName, $html",
1841        "visit_form" => "$ctx, $actionUrl, $method",
1842        "visit_input" => "$ctx, $input_type, $name, $value",
1843        "visit_audio" | "visit_video" | "visit_iframe" => "$ctx, $src",
1844        "visit_details" => "$ctx, $isOpen",
1845        "visit_element_end" | "visit_table_end" | "visit_definition_list_end" | "visit_figure_end" => "$ctx, $output",
1846        "visit_list_start" => "$ctx, $ordered",
1847        "visit_list_end" => "$ctx, $ordered, $output",
1848        _ => "$ctx",
1849    };
1850
1851    let (action_type, action_value, return_form) = match action {
1852        CallbackAction::Skip => ("skip", String::new(), "dict"),
1853        CallbackAction::Continue => ("continue", String::new(), "dict"),
1854        CallbackAction::PreserveHtml => ("preserve_html", String::new(), "dict"),
1855        CallbackAction::Custom { output } => ("custom", escape_php(output), "dict"),
1856        CallbackAction::CustomTemplate { template, return_form } => {
1857            let form = match return_form {
1858                TemplateReturnForm::Dict => "dict",
1859                TemplateReturnForm::BareString => "bare_string",
1860            };
1861            ("custom_template", escape_php(template), form)
1862        }
1863    };
1864
1865    let rendered = crate::template_env::render(
1866        "php/visitor_method.jinja",
1867        minijinja::context! {
1868            method_name => method_name,
1869            params => params,
1870            action_type => action_type,
1871            action_value => action_value,
1872            return_form => return_form,
1873        },
1874    );
1875    for line in rendered.lines() {
1876        setup_lines.push(line.to_string());
1877    }
1878}
1879
1880/// Returns true if the type name is a PHP reserved/primitive type that cannot be imported.
1881fn is_php_reserved_type(name: &str) -> bool {
1882    matches!(
1883        name.to_ascii_lowercase().as_str(),
1884        "string"
1885            | "int"
1886            | "integer"
1887            | "float"
1888            | "double"
1889            | "bool"
1890            | "boolean"
1891            | "array"
1892            | "object"
1893            | "null"
1894            | "void"
1895            | "callable"
1896            | "iterable"
1897            | "never"
1898            | "self"
1899            | "parent"
1900            | "static"
1901            | "true"
1902            | "false"
1903            | "mixed"
1904    )
1905}