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