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