Skip to main content

alef_e2e/codegen/
php.rs

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