Skip to main content

alef_e2e/codegen/
php.rs

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