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