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