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