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